某电商 CMS 最新版
看到这个电商框架有个图片转 base64 接口同时用了 curl_exec + file_get_contents + parse_url,直接冲了。
0x01 入口
路由文件 app/api/route/v1.php:
1 | Route::post('img_base64', 'v1.PubCtrl/img_base64'); |
挂在 unauth 分组:
1 | ->middleware(\app\api\middleware\AuthMiddleware::class, false); |
第二个参数 false 意思是 token 可选 → 未授权可达。
0x02 LFI 漏洞核心函数
app/common.php (line 502):
1 | function img_to_b64($avatar = '', $timeout = 9) |
两个坑:
str_replace('https', 'http', $url)—— 子串替换。发httpss://→ 替换后https://,scheme 走私回来。- site_url gate 只比
scheme + host,path 完全用户控制,path = /../.env越出 public 目录读到项目根.env。
.env 里最有价值的字段是 APP_KEY(后面 JWT 签名要用)、CACHE_PREFIX(后面 Redis 键名要用)、REDIS_HOSTNAME/PORT(后面 SSRF 目标)。拿到 APP_KEY 本以为签一枚 admin JWT 就能进后台,一试直接 401,问题在下一节。
0x03 缓存门 —— 401 的真正原因
带着伪造的 JWT 打 adminapi,后端走 AuthService::parseToken(app/services/AuthService.php:60):
1 | $md5Token = md5($token); |
只查 Redis 里 md5(token) 这个键存不存在,值只要 truthy 就行。没做任何 payload 和缓存值的一致性绑定。
意思是”这个 token 我发过没?→ 看 Redis 里 md5(token) 存不存在 → 存在 = 发过”。开发者忘了 Redis 也可能被别人写。
想过这道门 → 只需要往 Redis 里塞一个键,键名 = CACHE_PREFIX + md5(我的伪造JWT),值任意 truthy。
但 Redis 只监听 127.0.0.1(.env 里能看到),外网直连不上。只能让后端替我发 Redis 命令 —— SSRF。
0x04 SSRF 投毒缓存
回到 0x02 那个 img_to_b64 函数,site_url gate 不命中的时候走 else 分支,里面是 curl:
1 | function img_to_b64($avatar = '', $timeout = 9) |
关键:没设 CURLOPT_PROTOCOLS。libcurl 默认协议白名单里就有 gopher / dict / ftp / file / smtp。发 image=gopher://xxx:yyy/_数据,curl 就会打开 TCP 到 xxx:yyy 把数据原样发过去。
Redis 用的是 RESP 明文协议。所以 gopher://127.0.0.1:6379/_<URL 编码的 RESP> 等于对 127.0.0.1:6379 发一条 Redis 命令。这是十几年前的老 SSRF 技巧,curl 一直没修。
但 controller 里 curl 前面有个 host 白名单:
1 | // app/api/controller/v1/PubCtrl.php |
Request::host() 加进白名单 —— 这里就是绕点。框架默认 trust_proxy=on,Request::host() 优先返回 X-Forwarded-Host header 的值。
攻击者只要发 X-Forwarded-Host: 127.0.0.1,白名单里就多了 127.0.0.1,gopher URL 里 host 是 127.0.0.1,通过,curl 打到本地 Redis。
我们要发的 Redis 命令:
1 | SET <CACHE_PREFIX><md5(伪造JWT)> a:1:{s:3:"uid";i:1;} EX 1800 |
- 键名两部分:
CACHE_PREFIX从 .env 拿,md5(JWT)依赖 JWT 具体值 - 值
a:1:{s:3:"uid";i:1;}= PHPserialize(['uid'=>1]),unserialize 是非空数组 = truthy EX 1800= 30 分钟 TTL,够用够跑
问题来了:JWT 现在还没造。所以下一节先造 JWT,拿到它才能算 md5、拼键名、造 gopher payload。
0x05 伪造 admin JWT
后台用的是 JWT,签名算法从 /utils/JwtAuth.php 里看:
1 | $token = JWT::encode($params, Env::get('app.app_key', 'default'), 'HS256'); |
HS256 = HMAC-SHA256,对称密钥,同一把 key 既签又验。key 就是 .env 里的 APP_KEY,我们上一步已经拿到了。
看 controller 里 login 时怎么塞 payload:
1 | $params = ['jti' => ['id' => $adminInfo->id, 'type' => 'admin']]; |
payload 里只有 jti.id 和 jti.type 决定身份。装机 SQL 里超管固定是 id=1(INSERT INTO system_admin VALUES (1, 'admin', ...)),所以永远伪造 id=1, type=admin。
再看 parseToken 里最后一道验证(第 0x03 节缓存门后面):
1 | [$id, $type, $pwd] = $jwtAuth->parseToken($token); |
条件门。PHP 短路语义:$pwd !== '' && ... 里如果 $pwd 是空字符串,左边 false,右边不算。所以 payload 里不写 pwd 字段就等于空串,门直接跳过。
JWT 结构就三段用 . 分隔:<Header>.<Payload>.<Signature>,每段 Base64URL 编码(不是普通 Base64,+/= 换成 -_ ,末尾 = 去掉)。签名 = HMAC_SHA256(APP_KEY, Header + "." + Payload)。
完整伪造代码,可直接跑:
1 | import base64, hmac, hashlib, json, time |
拿到手的 JWT 可以粘到 jwt.io 检查 —— 只要 secret 填对(输入我们知道的 APP_KEY),它会显示 “Signature Verified”。
这枚 JWT 接下来会被用两次,别以为只是最后 header 塞一下:
- 上一节 gopher 里用:算
md5(这枚 JWT),拼上CACHE_PREFIX得到 Redis 键名。种别的键完全没用,后端只查这枚 JWT 对应的那个键。 - 最后打 adminapi 用:
Auth-Header: Bearer <JWT>。后端拿它再算一次 HMAC 和 Signature 段比对(verifyToken(APP_KEY)),只有真的用 APP_KEY 签的才能过。
JWT + gopher 是绑死的:先造 JWT → 算 md5 → 造 gopher payload → 种缓存 → 再拿 JWT 打后台。顺序反了、JWT 换了,整条链都断。
0x06 数据流
1 | POST /api/img_base64 image=httpss://<site>/../.env |
0x07 数据包
LFI 读 .env:
1 | POST /api/img_base64 |
响应 data.image 是 base64 编码的 .env 内容,含 APP_KEY=app_key_xxxxxxxxxxx、CACHE_PREFIX=cache_xxxxx:。
site_url 域名要抠对(有的站是 www. 有的是裸域),从公开 API 响应里抠图片 URL 前缀:
1 | GET /api/home |
响应里图片资源类似 "banner":[{"img":"https://www.target.com/static/images/xxx.png"}],static/images/* 是应用内置静态路径,前缀等于 site_url。
造 gopher payload(接 0x05 拿到的 jwt):
1 | import hashlib, urllib.parse |
SSRF 种缓存:
1 | POST /api/img_base64 |
响应 {"data":{"code":false,"image":false}} —— gopher 无 HTTP 状态,curl_exec 认为失败,但 SET 已在 Redis 落地。
验证接管:
1 | GET /adminapi/config/get_system/site_name |
1 | {"status":200,"msg":"success","data":{"value":"<真实站名>"}} |
401 → 200,超管接管完成。后续 /adminapi/* 任意端点(用户列表 / 订单 / 云 AK/SK / 支付密钥)全解锁。