☰
从SQL注入到系统权限:读数据、写文件、执行命令全解析
2026/9/25 13:25:38 网站建设 项目流程

1. 从注入点到系统权限:SQL注入利用的三个阶段

很多人学SQL注入,停留在' or 1=1--这种万能密码绕过或者union select拖个数据库就完事了。但实际上,一个注入点能做到的事情远不止"把数据拿出来"这么简单——只要权限够、条件允许,你完全可以从数据库一路打到操作系统层面。这篇文章我就把SQL注入的三种利用方式完整串一遍:读数据、写文件、执行命令。

先说清楚一个前提:SQL注入的本质是攻击者控制了拼接到SQL语句里的"数据"部分,让数据库把它当成"代码"来执行。当这个控制能力足够强时,数据库就成了攻击者在目标内网里的一台"跳板机"。读数据是最基础的,写文件是把数据变成"武器",执行命令则是武器真正开火。

我见过不少新手拿着sqlmap跑出--dump,觉得"注入练完了"。其实不是的。真正的实战链路应该是:注入点 → 判断类型 → 读数据 → 尝试写文件 → 拿到命令执行能力。每一步都有前置条件,每一个前置条件都是可以提前探测的。这篇文章我会结合自己在靶场和其他授权环境里的实操经验,把每一步的原理、操作、坑都讲清楚。

文章会涉及MySQL为主,穿插PostgreSQL、SQL Server等常见数据库的差异。适合正在学SQL注入的入门者,也适合已经会跑sqlmap但想深入理解底层原理的人。

2. 读数据:注入点的第一价值就是"把库里的东西拿出来"

2.1 先搞清楚你想读什么

SQL注入能读的数据,简单说就是数据库里存的所有东西——业务数据、用户账号密码、会话Token、支付信息、后台管理员账号,甚至其他数据库的凭据(如果配置了DBLink)。但真正专业的做法不是上来就select * from users,而是先搞清楚:目标数据库类型是什么?当前用户是什么权限?有哪些库、哪些表、哪些字段是有价值的?

这就是information_schema存在的原因。在MySQL里,information_schema是一套只读的系统视图,存着所有数据库的元数据。攻击者拿到注入点之后的第一步,通常是读information_schema.tables和information_schema.columns。

以MySQL为例,判断数据库类型的特征很直观:@@version返回类似8.0.32的版本号,concat()里的分隔符拼接出来会有明显的MySQL标志。而如果是PostgreSQL,version()函数返回一串很长的PostgreSQL版本信息。判断类型为什么要放在第一步?因为后面的读文件、执行命令手法在不同数据库里差异极大,判断错了全白干。

2.2 联合查询与报错注入的取舍逻辑

读数据的常见注入手法就四种:联合查询注入、报错注入、布尔盲注、时间盲注。各有适用场景,选择逻辑很简单:能并查出结果就别用盲注,能报错回显就别用时间盲注。

联合查询注入(Union Select)适合页面有回显位置的注入点。比如一个新闻详情页,URL里的id=1对应了页面上的标题和正文,这时候你就可以通过union select控制输出的内容。核心技巧是探字段数:order by 3能正常、order by 4报错,说明是3个字段。然后找到回显位置,把数据放到显示出来的那个字段位子上。

看似简单,但很多人第一次写union select就翻车了。常见问题是:union两侧的列数不一致、忘了前一条select的查询结果会被当作第一行、数据类型对不上(比如字段A在页面上是数字,你回显一个字符串它可能直接不显示)。我的习惯是先把联合查询的payload写成:id=-1 union select 1,2,3,用一个不存在的id值把原始查询结果清空,这样页面上就只剩我控制的内容,回显位置一目了然。

报错注入的适用场景是页面没有直接的数据回显,但数据库报错信息会原样输出。MySQL里最常用的两个函数是updatexml()和extractvalue(),它们的报错信息会包含我们传入的表达式。比如:

id=1 and updatexml(1,concat(0x7e,(select user()),0x7e),1)

因为XPATH语法错误,MySQL会把concat()里的内容拼进报错信息返回。注意0x7e是波浪号~,用来在报错信息里做分隔标记,防止报错信息被截断看不清。

一个关键细节:报错注入的payload里,子查询的结果长度有限制——updatexml和extractvalue的报错内容最多显示32个字符。所以遇到超长内容,比如一个36位的UUID,或者一长串加密hash,你直接查就会看到内容被截了一截。这时候要用reverse()、substr()等函数分段读取,我在实战里经常一个字段要分好几段拼接才能读全。

2.3 数据读不出来的两个大坑

坑一:盲注场景下,数据不是"读"出来的,是"猜"出来的。当页面既不回显数据,也不回显报错信息,只有"登录成功/失败"或"页面正常/空白"这种二元状态,你就只能走布尔盲注:构造一个条件,让页面状态因条件真假而变化,然后逐字符拆解数据。sqlmap的--technique=B就是干这个的,但手工盲注的能力也得练——至少你要知道ascii(substr((select database()),1,1))>65这种判断逻辑是怎么一步步跑出来的。时间盲注同理,只是把判断条件变成了sleep(5)是否生效。

坑二:数据量超出单次查询限制。联合查询一次只能回显一行,报错注入一次只能拿32个字符,盲注一次只能确认一个字符。所以读大量数据时必须配合limit、group_concat()、substr()这些函数拆分。尤其是group_concat(),它默认的长度上限是group_concat_max_len(默认1024字节),遇到一个表几百条记录时,超出的部分不会报错,只会静默截断。我当时调试半天才发现是自己用的姿势不对,后来都写成:

union select group_concat(username,0x3a,password separator 0x3c62723e) from users

并配合session变量临时改大group_concat_max_len来确保数据读全。

2.4 不同数据库的读法差异

PostgreSQL的元数据不在information_schema里,但很多关键信息在pg_catalog里。常用的是:

-- 查询所有数据库 select datname from pg_database; -- 查询当前用户 select current_user; -- 查询所有表 select tablename from pg_tables where schemaname='public';

SQL Server则是用sys.databases、sys.tables、sys.columns这套系统视图,db_name()获取当前库名,user_name()获取当前用户。Oracle要用all_tables、all_tab_columns这些数据字典。读数据的底层逻辑都一样——先元数据、再数据本身、最后按需拆分读取,换数据库只是换一套系统表的名称。

3. 写文件:从"能读"到"能落盘"的跨越

3.1 secure_file_priv:MySQL写文件的生死线

secure_file_priv是MySQL里控制LOAD_FILE()和INTO OUTFILE行为的安全参数。它的三种取值直接决定了你能不能写文件:

取值含义
NULL禁止文件读写操作
空字符串不限制,任意路径可读写
指定目录路径只能在指定目录下读写

MySQL 5.7及以上版本默认是NULL(不能读写文件),MySQL 5.5及更早版本默认是空字符串(不限制)。这也是为什么很多老教程里"拿到注入点直接写shell"的玩法在现在的靶场里经常失效。遇到不能写的情况,优先排查:当前用户有没有FILE权限、secure_file_priv是不是NULL、能不能通过配置文件修改(如果DB是独立部署的,写不了/tmp但能写web目录,直接改配置文件重启就行,但通常你没有这个权限)。

判断这两个条件是否满足的SQL:

-- 查当前用户权限 select user,file_priv from mysql.user where user=current_user(); -- 查secure_file_priv当前值 show variables like 'secure_file_priv';

注意一点:secure_file_priv是全局变量,读它需要足够权限。如果注入点权限较低,直接show variables可能被拒。这时候可以尝试用报错注入的方式去套变量值,比如extractvalue(1,concat(0x7e,(select @@secure_file_priv),0x7e)),运气好就能读到。

3.2 INTO OUTFILE和INTO DUMPFILE:一字之差,用法完全不同

INTO OUTFILE把查询结果原样写入文件,多行数据会以文本形式保存。INTO DUMPFILE写入的是单行二进制内容,不做行尾转换,适合写需要保持精度的内容,比如导出的二进制文件(图片、压缩包、已经被编码成hex的webshell)。

写shell的标准payload是:

select '<?php @eval($_POST["x"]);?>' into outfile '/var/www/html/shell.php'

但很多情况下直接写会失败,原因通常是:目标web目录有权限限制、secure_file_priv限制、或者php文件被WAF拦截。这时候有一个改良思路是先把内容hex编码,再用0x写入:

select 0x3C3F70687020406576616C28245F504F53545B2278225D293B3F3E into outfile '/var/www/html/shell2.php'

数据库在执行时会自动把0x...十六进制串解码成二进制内容落盘。这个技巧还有一个额外好处:可以绕过低版本MySQL对引号内容的特殊处理,让payload本身不包含单双引号,在多个环节上降低被检测的概率。注意仅用于授权的靶场和环境,在自己负责的系统中做测试时要提前做好日志清理与备份。

另一个经常被忽略的细节是文件写入路径里目录必须存在。如果你写/var/www/html/test/shell.php但test目录不存在,MySQL不会自动帮你创建目录,直接报错。所以写文件之前先确认目录存在,或者在SQL里用@@datadir、@@basedir等变量辅助拼路径,看看站点根目录到底在哪。

3.3 写日志文件:不依赖OUTFILE的备选方案

当INTO OUTFILE被secure_file_priv按死、无法写入web目录时,还有一个路径是通过MySQL日志文件来写内容。原理是:MySQL的general_log会把所有执行的SQL记录到日志文件里。如果你能修改全局变量general_log和general_log_file,就可以把日志文件指向一个web目录下的php文件,然后执行一条包含webshell代码的查询语句,日志文件里就会"记录"这一行webshell代码。

操作命令:

set global general_log = on; set global general_log_file = '/var/www/html/cmd.php'; -- 执行包含webshell的查询(写成注释里最保险,防止被日志截断) select '<?php @eval($_POST["x"]);?>'

然后把日志文件路径指回原位置、关闭general_log,请求cmd.php,一个"日志"变成的WebShell就上线了。这个技巧在CTF和真实授权测试中经常作为OUTFILE失败后的Plan B。前提是当前用户具备SUPER权限来修改全局变量。

3.4 写文件到底能不能成功,先看这三个条件

综合下来,SQL注入写文件成功的必要条件,一个都不能少:

  1. 当前用户有FILE权限,或者能通过提权拿到FILE权限
  2. secure_file_priv没有限制到NULL,允许向目标目录写文件
  3. 目标路径可写,web目录有写权限且目录存在

一旦这三个条件同时满足,写文件基本就是一次SQL语句的事。我在实际测试里见过最成功的一次场景是:站点的上传目录权限没做收口,secure_file_priv是空字符串,直接把一句话写进上传目录,然后通过GET参数拼接php代码执行,整个过程没有触发任何安全告警——因为流量从外表看只是普通的上传目录访问日志。

4. 执行命令:从WebShell到系统权限的红利最大化

4.1 MySQL UDF:老技术,但原理值得吃透

拿到WebShell之后,紧接着想做的就是命令执行。老版本的MySQL有一个经典玩法——UDF(User Defined Function),通过自定义函数调用系统命令。原理很简单:MySQL允许用户自定义函数,函数用C/C++编译成.so文件放在系统目录里,然后用SQL调用它。

整个利用过程大概是:

  1. 拿到WebShell后,确认MySQL插件目录:select @@plugin_dir;
  2. 把编译好的udf.so文件写入插件目录,或者通过SQL把UDF的十六进制内容直接dumpfile进插件目录
  3. 创建自定义函数:create function sys_exec returns string soname 'udf.so';
  4. 执行系统命令:select sys_exec('id');

这个玩法在MySQL 5.1及以前非常流行,因为插件目录权限要求低、UDF文件可以直接用SQL写入。但现在主流MySQL版本默认不加载plugin_dir之外的文件,UDF提权已经很难走通了。而且安全产品对create function这类语句的监控已经非常成熟。所以在今天的实战中,UDF更多是用来理解"数据库如何变成命令执行入口"的教学案例,而不是首选武器。

4.2 更现实的命令执行路径

今天我们拿到SQL注入点后,更现实的命令执行链路其实是:

  1. 读数据,搞到管理员账号和后台地址
  2. 登录后台,找到后台本身就能执行系统命令的功能(比如计划任务、模板编辑、命令模块)
  3. 如果在后台找不到直接入口,用注入点的写文件能力落一个WebShell
  4. WebShell连接菜刀/冰蝎/蚁剑,利用面板自带的虚拟终端执行命令

这条链路每一步都有充分的前置条件、每一步都能被审计,但绝大多数情况下这就是实战中进展最快的那条路。"SQL注入直接执行命令"并不是一个常态,常态是组合拳。

如果一定要找一个"数据库本身就能执行命令"的现代场景,PostgreSQL有一个特性很值得关注:COPY ... FROM PROGRAM。PostgreSQL运行了pg_execute_server_program权限的用户可以直接通过它调用系统命令:

copy (select '<?php @eval($_POST["x"]);?>') to program 'echo "<?php @eval($_POST[\"x\"]);?>" > /var/www/html/cmd.php';

这是一个真正"一条SQL命令执行系统命令"的案例。在SQL Server里对应的是xp_cmdshell扩展存储过程,Oracle则是通过Java存储过程或外部程序。但这些"真·命令执行"功能在现代数据库里默认都是关闭的,MySQL甚至直接在设计上就没有这种等价物。所以这个问题要辩证看:数据库可以执行命令吗?很多时候可以,但需要特定数据库、特定权限、且通常要做一些配置上的冒险。

4.3 为对抗检测做的"低痕迹"优化

不管走哪条路,凡是涉及到写文件、改配置、执行命令,都会留下痕迹。从红队视角看,有几个细节点要注意:

  • 写WebShell的文件名避免使用shell.php、cmd.php这类特征明显的名字,可以用图片名、皮肤文件、上传临时文件名等伪装。
  • 连接WebShell时使用加密马(如冰蝎、哥斯拉),避免明文POST特征。
  • 修改general_log文件后要记得恢复原值,避免服务异常导致被排查。
  • MySQL和PHP版本差异可能导致WebShell解析失败,运维环境里官方PHP-FPM与Apache的解析条件不完全一致,写马之前可以先探测一下解析环境。

这些点不是帮你违法,而是帮你在授权测试里更真实地评估风险暴露面。防御方也会看这些特征来排查入侵,攻防本来就是互相学习的过程。

5. 从攻击链路反推防御重点:这是一篇给蓝队也看的文章

5.1 注入点是怎么被"炼"出来的

如果想做好防御,就要先理解攻击者的三步走:探测注入点、确认利用条件、锁死利用链。

探测注入点的常见动作是加单引号、加and 1=1和and 1=2、加sleep()等让响应出现时间差。这些流量特征在WAF层面大部分都能识别,所以攻击者会做各种编码绕过:URL编码、二次编码、大小写混淆、注释符拼接、等价函数替换(substr换mid)、利用%0a或%23截断等等。

关键是所有绕过都发生在SQL层,而根治方法是在代码层用参数化查询。我在无数项目里反复说同一句话:只要你的SQL语句是从用户输入拼接出来的,WAF再强也是事后补救;换成预处理语句(PreparedStatement)之后,注入在语法解析阶段就不可能成立。

5.2 数据库侧的几个硬配置

如果代码一时改不了,数据库侧这几个配置能直接砍掉文章里大部分攻击路径:

  1. secure_file_priv设为NULL或严格目录白名单,直接封死写文件路径
  2. 业务账号权限最小化:禁FILE、禁SUPER、禁GRANT,用哪张表就给哪张表的SELECT/INSERT/UPDATE/DELETE
  3. general_log默认关闭,日志文件路径不能落在web可访问目录
  4. 数据库不要用root/system账号跑,连接串里用独立低权限账号
  5. 部署数据库审计,把create function、into outfile、set global这类高危语句单独告警

这些点做下来,SQL注入即使存在,攻击者的利用路径也会被中断在"读数据"阶段——绝大多数业务泄露事件,实际上到"拖库"这一步就足以造成毁灭性损失了,别等攻击者写到WebShell才后悔。

5.3 我被问过最多的几个防御误区和一次真实的应急响应经验

有个经典的误区是"我们用了ORM,所以肯定没有SQL注入"。很多人不知道ORM也有原生查询接口,更不知道order by、in、like这些动态拼接是ORM拦不住的。我在一次应急响应里看过整套Spring Boot + MyBatis的系统,开发只把${}用在了排序字段上,就这么一个口子,整个后台的用户表都被拖干净了。

那次应急响应的排查链路让我印象很深:发现数据库binlog里有连续的大批量select * from记录,追到访问日志发现请求参数里带着order by (case when ...的布尔盲注特征。修复方案其实很简单——把动态排序字段改成白名单校验,不在白名单里就直接返回默认排序。但就是这种"小地方",最容易出大事故。

还有团队问我:"数据库账号已经做了最小权限,注入还能拖库吗?"答案是可以——很多业务账号天然就有SELECT全库的权限,最小权限≠没有权限。真正要做的,是给业务账号限定它真正需要的那几张表,连information_schema的读取权限都做收敛。这个动作很多DBA觉得"没必要",但一旦被拖库,后悔药是没有的。

6. 写在最后:别把SQL注入当"过时话题"

我见过太多新手觉得SQL注入"太老了""现在哪还有这种漏洞"。但2023年底到2024年仍然有大量真实漏洞通报是SQL注入向的,关键不是"注入"这个漏洞形式消失,而是利用路径变窄了:默认安全配置收紧了、数据库版本升级了、WAF变聪明了。但这不意味着利用手法没用了——恰恰相反,读数据、写文件、执行命令这条利用链,在任何一次数据库相关的渗透测试里都是主链路,只是每一步的门槛都变高了。

练的话,我建议在本地搭一套DVWA或SQLi Labs靶场,先手工把联合查询、报错注入、盲注都跑一遍,再上sqlmap对比结果。然后试着把secure_file_priv改成空字符串,做一次真实的文件写入和日志写入实验——注意务必在授权、隔离的靶机环境中操作,这一步能帮你建立"写入失败的原因排查"的体感。最后再研究UDF、COPY FROM PROGRAM这些原理解析,配合隔离环境里的docker做破坏性研究就足够了。

最后再分享一个个人体会:很多人写文件失败就立刻怀疑是被安全设备拦了,但实际操作里超过一半的情况只是目录不存在、权限不够、或者secure_file_priv没查。先把数据库侧的前置条件完整排查一遍,再考虑对抗层面的东西,效率会高很多。SQL注入的学习没有捷径,就是一次一次把payload从"能跑"变成"跑得优雅",再从"跑得优雅"变成"跑得隐蔽"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询