CTF实战:巧用MySQL REPLACE函数绕过WAF实现SQL注入

📅 发布时间:2026/7/16 1:30:44 👁️ 浏览次数:
CTF实战:巧用MySQL REPLACE函数绕过WAF实现SQL注入
1. 从一道“不可能”的CTF题说起最近在复盘一些经典的CTF题目时我又遇到了那道让我印象深刻的“yet_another_mysql_injection”。说实话第一次看到题目要求时我整个人是懵的。题目逻辑看起来简单到离谱给你一个登录框用户名必须是admin然后你需要输入密码。后端代码会把你输入的密码拼接到SQL语句里去查询如果从数据库里查出来的密码和你输入的密码完全一样就会给你FLAG。这听起来是不是很矛盾我连数据库里存的是什么都不知道怎么可能输入一个和查询结果一模一样的密码更绝的是题目还明确告诉你users表里根本没有数据是一条空记录。这意味着传统的思路——比如爆破密码、或者用盲注把密码一位位猜出来——在这里完全行不通。因为你查询的结果本身就是空的你怎么可能让一个空值等于你输入的任意字符串呢我当时卡在这里很久感觉题目设计者是不是在开玩笑。但CTF的魅力就在于此它总能把一些看似平常的函数玩出你意想不到的花样。这道题的核心枷锁就是这个“输入必须等于输出”的变态校验。而打开这把锁的钥匙就是MySQL里一个非常基础却在此处能化腐朽为神奇的字符串函数REPLACE。更具体地说是一种叫做“Quine”的自指技巧。别被名字吓到接下来我会用最直白的方式带你一步步拆解这个精妙的构造过程你会发现所谓的“神技”背后是一层窗户纸捅破了也就那么回事。2. 深入靶场代码审计与WAF规则分析在动手构造Payload之前我们必须像侦探一样把现场环境摸个透。题目给出的PHP源码就是我们的“案发现场记录”。我们得逐行分析找到所有限制和可能的突破口。首先用户名被写死必须是admin所以注入点完全集中在password这个参数上。后端会构造这样一条SQL语句SELECT password FROM users WHERE usernameadmin and password$password;这里的$password就是我们可控的输入。看起来是个标准的字符型注入点。紧接着一道强大的WAFWeb应用防火墙拦在了前面。checkSql函数里用preg_match设置了一长串黑名单/regexp|between|in|flag||||and|\||right|left|reverse|update|extractvalue|floor|substr||;|\$|0x|sleep|\ /i我们来翻译一下这个黑名单都禁了啥常见运算符和逻辑符、、、and、|。这基本上堵死了布尔盲注和比较操作。关键函数regexp、between、in、right、left、reverse、substr、extractvalue、floor、sleep。报错注入、时间盲注、字符串截取的路子也被封了。特殊字符、;、$、0x。防止了堆叠注入和十六进制编码的简单绕过。敏感词flag。直接防止你读取目标。这个过滤规则相当严格很多常规的注入手段一上来就哑火了。但仔细看它并没有禁掉union和select也没有禁掉replace函数。这给了我们一线生机。最后也是最关键的一环是那个“死亡校验”if ($row[password] $password) { die($FLAG); }$row[password]是数据库执行我们注入的SQL语句后返回的结果$password是我们原始输入的内容。它要求这两个值严格相等。在users表为空的情况下$row[password]通常是NULL。想让NULL等于我们输入的字符串除非我们输入也是NULL但这显然无法通过WAF也不符合逻辑。那么破局点在哪里既然查询结果必须等于我们的输入我们能不能构造一个特殊的输入让数据库执行后返回的结果恰好就是这个输入本身呢就像对着镜子说一句话镜子里的回声和你说的原话一模一样。这就是“Quine”程序的思想。而在这个SQL语境下REPLACE函数就是我们制造这面“镜子”的工具。3. REPLACE函数与Quine理解自指的核心魔法在施展魔法前得先弄明白我们的魔法棒——REPLACE函数——到底怎么用。它的语法很简单REPLACE(str, from_str, to_str)作用就是在字符串str里找到所有from_str子串把它们全部换成to_str然后返回新字符串。听起来平平无奇对吧我们来看一个最基础的例子SELECT REPLACE(hello world, world, ctf);输出是hello ctf。这没什么特别的。现在我们来点“自指”的玩法。看这个语句SELECT REPLACE(., ., .);这个语句有意思了它要把字符串.中的.替换成.。这相当于什么都没做结果返回的还是.。输入是REPLACE(‘.’, ‘.’, ‘.’)输出是.。输入和输出并不相同。怎样才能让输出和输入的整个SQL表达式一样呢我们得让REPLACE操作的对象第一个参数和替换的结果第三个参数产生关联。试试这个SELECT REPLACE(REPLACE(\.\, CHAR(46), \.\), CHAR(46), REPLACE(\.\, CHAR(46), \.\));这里我用CHAR(46)来代表点号.主要是为了在构造时避免字符混淆它和.是等价的。我们来拆解一下第一个参数要处理的字符串REPLACE(\.\, CHAR(46), \.\)。注意里面的双引号是字符串的一部分。第二个参数要查找的字符串CHAR(46)也就是.。第三个参数要替换成的字符串REPLACE(\.\, CHAR(46), \.\)和第一个参数字面量一样。这个语句的意思是在第一个参数字符串里找到所有的点号.然后用第三个参数字符串也就是它自己替换掉它们。执行一下结果会是什么结果是REPLACE(REPLACE(\.\, CHAR(46), \.\), CHAR(46), REPLACE(\.\, CHAR(46), \.\))请你仔细对比一下输出的字符串和我们最初输入的整个SQL语句的文本格式。你会发现除了最外层的引号从单引号‘变成了双引号“它们的内容结构是完全一致的我们几乎实现了一个“自产生”的SQL语句。这就是Quine在SQL中的一种表现形式一段代码其执行结果就是它自身的源代码。在这个例子里我们通过REPLACE函数的嵌套和自引用让输出无限接近输入。剩下的唯一障碍就是单引号和双引号的不一致问题。而解决这个问题只需要再加一层REPLACE。4. 实战构造一步步打造绕过WAF的完美Payload理解了原理我们就可以动手为这道CTF题量身定制Payload了。我们的目标很明确构造一个UNION SELECT查询让它返回我们精心设计的字符串。这个字符串必须满足Quine特性使得查询结果等于我们提交的原始Payload。整个Payload必须避开WAF的黑名单过滤。4.1 搭建基本骨架既然WAF没禁union和select我们可以用union select来直接控制查询返回的数据。因为users表是空的union select的结果就会成为整个查询的结果。所以Payload的骨架是这样的1 UNION SELECT ‘我们的Quine字符串’#为了让SQL语句语法正确我们需要闭合前面的单引号所以用1‘开头并用#注释掉后面的内容。WAF禁了空格我们用/**/这个MySQL注释符来充当空格这是绕过WAF的常见技巧。于是骨架进化成1/**/union/**/select/**/‘我们的Quine字符串’#4.2 注入Quine核心现在要把上一节我们研究的Quine结构装进去。我们最终需要的Quine完整形式是REPLACE(REPLACE(“待替换的字符串”, CHAR(34), CHAR(39)), CHAR(46), “待替换的字符串”)这里解释一下CHAR(34)是双引号CHAR(39)是单引号‘。第一层REPLACE的作用是把内部字符串里所有的双引号先替换成单引号‘。这是为了解决我们之前提到的引号不一致问题。CHAR(46)是点号.。第二层REPLACE的作用是把经过第一层处理后的字符串里所有的点号.替换成‘待替换的字符串’本身。这个‘待替换的字符串’我们称之为“种子”它本身也有固定的格式。这个“种子”的格式是REPLACE(REPLACE(‘.’, CHAR(34), CHAR(39)), CHAR(46), ‘.’)你可以把它看作一个“模板”它描述了我们整个替换规则。当我们把“种子”代入到上面的完整形式里魔法就发生了。4.3 组装与编码绕过现在我们把“种子”代入“完整形式”。为了在Payload中清晰地区分我们给“种子”加上一个标记比如在开头加上1“注意这里是双引号让它看起来更醒目。同时为了符合注入点的语法我们需要处理引号。最终构造的Payload如下1/**/union/**/select/**/replace(replace(1/**/union/**/select/**/replace(replace(.,char(34),char(39)),char(46),.)#,char(34),char(39)),char(46),1/**/union/**/select/**/replace(replace(.,char(34),char(39)),char(46),.)#)#看起来非常复杂我们把它拆解开看最外层1‘/**/union/**/select/**/replace(replace(‘...‘,char(34),char(39)),char(46),‘...‘)#这是我们的注入框架和Quine外壳。第一个‘...’内层字符串1/**/union/**/select/**/replace(replace(.,char(34),char(39)),char(46),.)#这是我们的“种子”注意它开头是1“双引号。第二个‘...’替换字符串内容和第一个‘...’一模一样。这个Payload的执行过程就是Quine的自指过程数据库接收到这个Payload执行UNION SELECT。它先计算最内层的replace(replace(‘.’,char(34),char(39)),char(46),‘.’)这个结果其实就是我们的“种子”字符串但引号可能变化。然后外层的replace开始工作先将内层字符串里的双引号全换成单引号‘再将所有的点号.替换成我们指定的第二个‘...’字符串。经过这一系列替换最终SELECT返回的字符串恰好就等于我们最初提交的整个Payload字符串从1‘之后开始的部分。于是$row[‘password’]的值就等于我们输入的$password值那个苛刻的if ($row[‘password’] $password)条件被完美满足FLAG成功到手。5. 举一反三REPLACE在SQL注入中的其他妙用通过这道题我们见识了REPLACE函数在构造Quine、绕过特定校验方面的强大能力。但这绝不是它唯一的舞台。在更广泛的SQL注入攻防中REPLACE以及类似的字符串函数如INSERT、SUBSTRING可以扮演多种角色。场景一绕过关键词过滤有些WAF会简单粗暴地过滤掉select、union等关键词。我们可以用REPLACE来“创造”出这些关键词。例如如果select被过滤我们可以先传入selselectect然后用REPLACE函数把中间的select替换成空字符串从而得到真正的select。当然这需要我们能控制查询的某个部分可以执行函数。例如在报错注入的updatexml或extractvalue参数中可以尝试构造and updatexml(1, concat(0x7e, (replace(selselectect, select, ))), 1)这样数据库执行时replace函数会先执行将‘selselectect’中的‘select’替换为空得到‘select’然后再作为updatexml的参数执行查询。场景二处理输出限制与字符集问题有时注入成功但输出被截断、编码或者有特殊字符处理。REPLACE可以用来清理输出。比如你想读取的内容里包含干扰的br标签你可以构造查询用replace(column_name, ‘br’, ‘’)来将其去除使得最终回显的内容更干净便于你提取信息。在盲注中如果你猜测的字符包含某些会被HTML实体编码的字符如、你也可以用REPLACE来测试和确认。场景三配合其他函数进行变形REPLACE可以和其他函数组合产生更复杂的效果。例如与MID/SUBSTR、ASCII、ORD等函数结合可以在被禁止使用substr的情况下通过替换特定位置字符的方式来逐位推断数据。虽然效率可能不如直接截取但在极端过滤下不失为一种思路。防御视角从防守方来看这道题给我们的启示是黑名单永远是不可靠的。即使过滤了上百个关键词和符号一个未在名单内的基础函数如REPLACE在特定逻辑下也可能造成致命绕过。更安全的做法是使用参数化查询Prepared Statements这是根治SQL注入的终极手段将用户输入始终作为数据而非代码的一部分。最小权限原则数据库连接账户只赋予最小必要的权限避免注入后能执行高危操作。逻辑校验与WAF结合像本题中“输入等于输出”这种业务逻辑漏洞WAF很难发现。需要在代码层面进行严格的逻辑检查避免出现此类“魔术”般的比较。对输入输出进行严格的类型和格式检查对于密码这类字段可以限制其长度、字符集避免输入过于复杂、包含代码特征的字符串。回过头看这道题的精妙之处在于它把代码审计、WAF绕过、SQL函数特性利用和逻辑漏洞结合在了一起。它不像那些直接堆叠注入、报错注入的题目那样直白需要你静下心来理解代码的每一层意图然后利用数据库最基本的功能去“欺骗”它。这种在严格限制下寻找唯一出路的思考过程才是CTF比赛和真实安全研究中最为宝贵的经验。下次当你再遇到看似“无解”的过滤时不妨想想是不是也有一个像REPLACE这样看似普通的函数在等着你用它玩出不一样的花样。