接到一个授权范围内的测试任务后,我习惯先打开目标主页面抓流量。这次的目标是一个部署在内网的在线文档管理系统,功能覆盖文档管理、团队协作、全文检索、版本历史和评论,体量不大但接口不少。第一轮浏览还没结束,一个带搜索功能的接口就把我拉回了旧时光——参数拼接导致的SQL注入非常典型,单引号直接触发报错。这个注入的价值很明确:文档库里可能存在敏感信息,能直接导出数据。但测试要求里有一条很显眼:授权范围内,要尽量说明漏洞影响,尤其是能否从SQL注入进一步升级为命令执行。于是我把思路从“拿到数据”切换到“SQLi to RCE链路能不能打通”,这才有了下文这堆思考。
为了行文脱敏,下文把这类在线文档管理系统统一称为“目标文档系统”,不出现具体产品名称。这篇文章适合三类读者:需要在实战里推进利用链的安全测试人员;想从攻击视角反推加固方案的开发和安全运维;以及正在学Web安全、想搞明白注入为什么能发展成RCE的学生。我会把每一步的判断依据和失败原因都写出来,而不是只丢结论。
1. 从SQL注入到命令执行:这条攻击链到底怎么走
1.1 文档系统为什么容易成为SQL注入的重灾区
我在测试目标文档系统之前,已经跟进过几款同类产品。整体感觉是:文档管理这类系统的技术债务普遍偏高。产品迭代太快,早期为了赶上线,大量接口直接拼SQL;后期功能堆上来,老接口又没人敢动。搜索、筛选、排序、导出、批量操作用到的参数非常多,每一个使用了字符串拼接的参数都是一个潜在注入点。更关键的是,很多文档系统为了保证全文检索、附件管理和统计报表能正常工作,数据库账号往往是高权限账号,甚至直接使用root。高权限加可注入参数,这条链路在起点上就已经具备了。
从影响范围的角度看,文档系统的特殊性还在数据资产本身。知识库、客户信息、内部设计文档、项目排期表都放在同一个库里,一次注入就等于一次数据泄露。安全测试写报告时,不能只写“存在SQL注入”,要把数据规模、敏感字段、账号权限、能否再扩展都讲清楚。这也是为什么我在拿到注入点之后,没有急着跑数据,而是先想“这条注入到底能把影响推到多远”。
1.2 整条利用链的思考框架
从SQL注入到RCE并不是一条笔直的路,中间要经过好几层判断。我习惯把链路拆成五环来看:
- 第一环:确认注入点是否能稳定读取库内数据,类型是字符型还是数值型,回显是报错、布尔还是时间盲注。
- 第二环:判断数据库类型、版本、当前账号权限,这是决定后续所有路线的前提。
- 第三环:探测数据库的文件读写能力,包括 secure_file_priv、日志开关、plugin 目录等全局状态。
- 第四环:根据前两环的结果,选择写Webshell、写日志、加载UDF或读源码后走二次利用。
- 第五环:验证命令执行,或者拿到能继续推进的敏感数据,将影响转化为报告结论。
这五环不是每一环都必须走通。前面三环是决策依据,最后一环才是RCE目标。很多人打不动,是因为直接跳到第四环去各种试写文件语句,忽略了权限和环境探测。MySQL的版本、账号的FILE权限、secure_file_priv的值,直接决定了哪些路径可用、哪些路径从一开始就是死路。
2. 注入点定位与权限探测(核心前置排查)
2.1 优先翻哪些接口?搜索、排序和批量操作
我在目标文档系统上首先翻的是列表和搜索类接口。原因很简单,这类接口参数多、逻辑杂,经常为了支持模糊查询、排序、分页而直接拼SQL。keyword、search、order、sort、page、limit、dir、id、field,这些参数都有嫌疑。常见的问题代码长这样:
$where = "where doc_title like '%" . $_GET['keyword'] . "%'"; $sql = "select * from doc where doc_status=1 " . $where;这段代码的问题非常直观:keyword 参数的内容被直接拼进 SQL 字符串,用户只要输入单引号,就可能破坏原有的闭合条件。判断注入类型时,不要一上来就丢给自动化工具。先手工试一个单引号,观察页面是否报错、报错信息是否回显SQL片段;再用布尔条件判断,比如 keyword=test' and '1'='1 与 keyword=test' and '1'='2 的返回结果是否有差异。如果页面没有明显差异,再考虑时间盲注,但时间盲注尽量不要在生产环境长时间测试,避免拖垮数据库。
2.2 数据库权限与文件能力探测是关键检查点
确认注入可控之后,真正决定能否RCE的是数据库环境和权限。很多人在这一步偷懒,直接跑数据,结果浪费了大量时间在不可行的写文件路线上。我会按照一个固定清单去提取信息,核心探测项如下:
| 探测项 | 查询表达式(示意) | 返回结果的意义 |
|---|---|---|
| 数据库版本 | SELECT version() | 决定可用函数、变量名和默认配置 |
| 当前用户 | SELECT current_user() | 判断是否为 root 或高权限账号 |
| 文件导入导出限制 | SELECT @@secure_file_priv | NULL、空字符串、指定目录,对应完全不同的写文件能力 |
| 日志开关与路径 | SELECT @@global.general_log, @@general_log_file | 判断 general_log 是否可开启、日志路径能否被改到Web目录 |
| 插件目录 | SELECT @@plugin_dir | UDF 需要写入的目录,路径不可用时直接放弃 |
这些查询在注入点里都可以通过 UNION 或报错注入拿到,优先级很高。拿到结果之后要做一次过滤判断:如果当前用户没有 FILE 权限,所有依赖 LOAD_FILE 和 INTO OUTFILE 的路线全部划掉;如果 secure_file_priv 是 NULL,说明 MySQL 服务端禁止了文件导入导出,INTO OUTFILE 写了也会报错;如果有 SUPER 权限,general_log 写文件这条路才真正可行。这一轮判断做完,后面至少能省一两个小时。
3. 四条从SQLi到RCE的可行路径
3.1 路径一:INTO OUTFILE 直接写Webshell,优先判断但成功率低于预期
这条路径的原理不复杂:MySQL 的 SELECT ... INTO OUTFILE 可以把查询结果写入服务器本地文件。如果Web应用和数据库部署在同一台机器上,又能猜到Web根目录的绝对路径,我们就可以把一个包含服务端脚本语言标记的文件写到Web目录里,然后通过URL访问触发命令执行。
它的前置条件非常苛刻,需要同时满足:
- 当前数据库账号具备 FILE 权限;
- secure_file_priv 为空字符串,或者指向Web目录;
- 能获取到Web应用的可写绝对路径;
- Web目录本身对 MySQL 进程有写权限;
- 数据库与Web应用同机部署。
判断顺序上,先查 @@secure_file_priv,再看路径。secure_file_priv 为 NULL 时,后面所有步骤都不用试了。如果是空或指定目录,可以尝试写入一个无恶意内容的探测文件,比如 test_scan_2025.txt,然后请求对应URL确认文件是否可达。确认可写并可达之后,再在本地复现环境里验证完整的Webshell写入。实际利用语句形如:
SELECT '<?php ... ?>' INTO OUTFILE '/var/www/html/xxx.php'这里不展开具体内容,关键是理解写文件的本质。这条路径在真实环境里成功率往往低于预期,原因很多:MySQL 8.0 默认 secure_file_priv 为 NULL、云数据库默认禁止文件操作、容器化部署导致Web与数据库分离、站点路径经过重写后无法直接访问文件。所以它适合作为第一优先级判断,但不适合作为唯一押注。
3.2 路径二:general_log 日志写文件,高权限账号的捷径
当账号能修改全局变量时,general_log 写文件是比 INTO OUTFILE 更实用的路径。它的原理是利用 MySQL 的通用查询日志:general_log 开启后,所有执行的 SQL 语句都会被记录到日志文件里。如果把日志文件路径改到Web目录,再执行一条带有脚本语言标记的SQL语句,日志文件就变成了一个可以被Web容器解析的文件。
这条路要能用,有几个关键条件:
- 当前账号具备修改全局变量的权限,通常是 SUPER 权限,MySQL 8.0 里也可能需要 SYSTEM_VARIABLES_ADMIN;
- 知道Web目录的绝对路径;
- MySQL 进程对目标目录有写权限;
- 数据库与Web同机部署。
操作顺序大致是这样:先确认能否修改全局变量,执行 SET GLOBAL general_log = 'ON';然后把日志路径改到Web目录,执行 SET GLOBAL general_log_file = '/var/www/html/temp_log.php';接着执行一条包含可被Web解析标记的SQL,让日志内容写进目标文件;最后访问文件验证,并在测试结束后恢复 original 配置、删除临时文件。
需要特别提醒的是,开启 general_log 会记录所有SQL,日志量增长非常快,对数据库性能和磁盘都是压力。安全测试中如果不小心把日志路径改到了Web目录,线上日志可能直接暴露其他业务数据。这类操作风险高,必须确认在授权范围内,并且在测试结束后马上恢复。相比 INTO OUTFILE,它的优势是不依赖 secure_file_priv,但代价是需要更高权限,而且日志内容容易不完整,存在写入失败或解析失败的情况。
3.3 路径三:UDF 动态库加载,老版本的高危选项
UDF 的完整名称是用户自定义函数,MySQL 允许通过加载动态库扩展函数。攻击者可以编译一个动态库文件,上传到 plugin 目录,再通过 CREATE FUNCTION 语句创建一个自定义函数,调用这个函数执行系统命令。
这条路径的限制条件更多,我在真实项目中很少遇到能直接打通的。需要:
- 当前账号有 FILE 权限或 DUMPFILE 写入能力,能上传二进制动态库文件;
- 能拿到 plugin_dir 路径,并且该目录可写;
- 动态库必须匹配操作系统位数和 MySQL 版本;
- MySQL 8.0 之后对 UDF 的加载限制变多,成功率进一步下降。
上传动态库通常会遇到 secure_file_priv 的限制,所以最终还是要回到文件写入能力上。如果前面已经确认 INTO OUTFILE 不可用,UDF 这条路的可行性也会大打折扣。不过在老版本、内网部署、管理不严格的系统里,UDF 依然是经典的一条RCE路径。我的建议是把它放在路线清单里,但不要抱太大指望,尤其是目标使用云数据库或较新版本时。
3.4 路径四:先读源码再找二次漏洞,最容易被低估
当数据库层所有写文件路线都被堵死时,SQL注入可能还能读取文件。LOAD_FILE 函数配合 FILE 权限,可以读取数据库服务器本地的文本文件。即使不能写入Webshell,读到应用配置文件也能带来决定性突破。
我遇到过不少系统,数据库注入点本身无法直接RCE,但通过 LOAD_FILE 读到了站点配置文件、nginx配置、数据库连接配置。拿到数据库密码后,可以直接登录数据库管理后台;拿到第三方API密钥后,可以控制附件存储;拿到后台加密密钥后,可以伪造登录态。这些都属于“SQLi to RCE”的变形——注入不直接给shell,但给了一条通往后台上传功能、模板编辑功能的凭证链。
这条路径特别适合两类场景:一是 MySQL 当前账号有 FILE 权限但 secure_file_priv 限制写入的场景,读文件通常不受写入目录限制;二是数据库和Web分离、但数据库服务器上存在敏感配置文件的场景。读文件的目标一般按优先级排列:Web站点配置文件、应用安装目录里的数据库配置文件、系统用户和定时任务文件、nginx和apache日志。拿到一条可行的凭证链,往往比硬啃写文件更有效。
4. 一次完整测试记录:从有望到碰壁
4.1 环境准备与工具选型
为了把这套思路完整跑一遍,我在本地搭了两套LNMP环境,一套 MySQL 5.7,一套 MySQL 8.0,都部署了旧版目标文档系统。工具方面,我主要用HTTP抓包工具来跟踪页面请求和参数,用注入检测工具快速确认常规注入类型,但关键判断全部用手工SQL。手工确认的原因很简单:自动工具能证明“有漏洞”,但解释不了“为什么这条路径可以、那条不行”,而后面这个问题恰恰是RCE链路决策的核心。
| 工具 | 用途 | 关键注意 |
|---|---|---|
| HTTP抓包工具 | 看接口参数、响应细节 | 适合分析请求结构,不适合直接改包盲测 |
| 注入检测工具 | 快速判断注入类型和数据类型 | 对复杂权限探测帮助有限,容易误报 |
| 数据库客户端 | 在本地环境验证写文件行为 | 避免将高危险语句放生产环境 |
| 文件监控 | 确认写入文件是否生效 | 用于验证日志写文件和OUTFILE结果 |
4.2 实测过程记录
这次测试的过程记录如下,我尽量把决策点写清楚。
定位注入点很顺利。目标文档系统的搜索列表接口有一个 keyword 参数,直接拼到了 SQL 查询里。手工加单引号后,页面返回了数据库语法错误,确认是字符型注入。注入检测工具随后确认了 UNION 注入可行,但页面上只有一个标题字段有回显,UNION 提取数据并不方便,于是我改用报错注入来逐条取数。
权限探测结果是第一个转折点。当前用户是 doc_user@%,权限只有 SELECT、INSERT、UPDATE、DELETE,没有 FILE 权限,也没有 SUPER 权限。这一步直接让 INTO OUTFILE、general_log、UDF 三条路全部进入不可行清单。为了严谨,我还是执行了 @@secure_file_priv 查询,返回结果在 MySQL 8.0 上是 NULL,进一步确认写文件路线完全关闭。
接下来尝试用 LOAD_FILE 读取应用配置文件,但因为账号没有 FILE 权限,语句直接报错。这时候我没有继续硬钻数据库权限提升,而是转向了数据层面的二次利用。通过报错注入,我在 information_schema 里翻到了应用配置表 doc_config,里面存着后台管理员密码哈希和一组API密钥。利用这些数据登录后台后,通过后台的模板编辑功能组合获得了命令执行。
整体结论是:SQL注入没有直接打通到RCE,但通过注入拿到了关键凭证,转到应用层之后才完成命令执行。这条链在真实环境里其实比直接写Webshell更常见,因为现代数据库环境对文件写入的限制越来越严格,而应用层往往还有更多入口可以组合利用。
4.3 为什么真实环境经常断在“最后一公里”
我在复盘这套流程时,整理了导致 SQLi 到 RCE 中断的常见原因:
- 云数据库托管:服务商通常禁用 FILE 权限,secure_file_priv 设置为 NULL,用户也没有 SUPER 权限;
- 容器化部署:Web 和数据库分离,即使数据库能写文件,也写不到Web目录;
- 站点路径不可预测:Nginx 做了 rewrite 或目录别名,写在真实路径下的文件无法通过URL访问;
- 文件系统只读:部分容器或加固系统对Web目录做了只读挂载;
- 安全防护:WAF 拦截写文件语句,数据库审计对 file 操作告警。
断在最后一公里,不等于漏洞没有影响。SQL注入本身、配置表暴露、凭证泄露,每一项单独提出来都是高危问题。区别只在于影响是“数据泄露级别”还是“命令执行级别”,报告里把这条利用链走到哪一步写清楚,比一味追求RCE更实在。
5. 防守方视角:如何斩断SQLi到RCE的链条
5.1 应用层断开注入入口
防守方最该做的第一件事,是在应用层把SQL注入的入口堵死。预编译语句是首选,它能让SQL结构与参数分离,用户输入无论怎么构造,都不会改变查询逻辑。PHP里用 PDO 预处理,Java 里用 PreparedStatement,都是成熟方案。对于已有的老代码,无法大规模重构时,至少要把搜索、排序、分页等高风险参数做白名单校验,阻止特殊字符进入SQL拼接片段。
关闭详细错误回显同样重要。很多注入能快速被确认,就是靠页面上直接暴露的SQL语法错误。开发阶段保留日志足够,生产环境要把数据库错误信息隐藏在日志里,页面只返回通用错误提示。
5.2 数据库层斩断权限提升路径
数据库侧的加固,核心是“最小权限”。业务系统连接数据库账号,绝不应该用 root 或具备 FILE、SUPER 权限的高权账号。建议至少做到:
- 业务账号只授予增删改查所需的表和字段权限,不授予 FILE、SUPER、GRANT 等全局权限;
- 在 MySQL 配置中设置 secure_file_priv=NULL,直接禁用文件导入导出;
- 日志文件必须存放在Web根目录之外,防止日志写文件被利用;
- 定期升级MySQL版本,修复旧版本中已知的权限绕过和UDF加载问题。
这套操作对大多数团队来说并不复杂,但收益极高。一旦业务账号没有 FILE 权限,SQL注入即使存在,也很难从数据库层翻到RCE。
5.3 运行时检测与应急响应
最后一步是在运行时发现攻击行为。WAF 可以拦截明显的注入特征,比如报错信息、文件写入函数、sleep函数等。数据库审计日志要重点关注几次敏感操作:SET GLOBAL、INTO OUTFILE、LOAD_FILE、CREATE FUNCTION,这些语句的出现往往意味着攻击者正在尝试提权。
文件完整性监控也可以发挥作用,对Web目录的异常新文件、日志目录的路径变化做告警。应急响应预案里,一旦发现可疑SQL,应该立即封禁来源IP、回收数据库账号权限、检查Web目录新增文件,并且结合前后日志做一次完整溯源。
6. 个人经验与思考
拿到 SQL 注入点之后,我现在的第一反应不是急着跑数据,而是先花二十分钟摸清权限边界。这套“环境判断优先于payload”的习惯,帮我避开了无数次无效操作。数据库版本、当前用户、FILE权限、secure_file_priv、日志路径,这五个点决定了大半个利用链的走向。
另外要提醒一点:不要低估数据层面的价值。很多情况下,SQLi 到 RCE 不是一条直线,而是先靠注入拿到一组关键凭证,再转到应用层完成命令执行。把“信息收集—凭证获取—二次利用”当成一条完整的链来思考,比死磕某个写文件语句要有用的多。
最后给防守方一个优先级建议:应用层做预编译、数据库层做最小权限,这两板斧配合起来,能斩断绝大多数 SQLi to RCE 的链路。我在实际项目里见过不少团队只上WAF、不管数据库账号权限,结果还是被从一条老接口打穿。所以,如果你时间有限,先去查业务数据库账号的权限,这个收益最直接。