Wouter Gritter
Software Developer | Electronics Hobbyist | Homelab Enthusiast

Finding and reporting a memory leak DOS in Simple Voice Chat

Posted on 18th of March 2026.

A while back I was helping diagnose repeated OOM kills on a live Minecraft network's proxy servers. The proxies would crash, restart, and die again almost immediately. While that turned out to have a different root cause, the investigation led me straight into a nasty memory leak in Simple Voice Chat's Velocity proxy support - one that any connected player could exploit to eventually crash the proxy.

This post covers how I found it, confirmed it with a PoC, and got it fixed upstream.

The investigation that started it

Someone on the team mentioned there had been talk of a "Simple Voice Chat exploit" floating around. Nothing confirmed, just a rumour. I filed it mentally and kept digging into the OOM crashes for now.

I got hold of heap dumps from both proxies. The first thing I noticed was that each ConnectedPlayer instance was consuming close to 700 KB of heap, with most of that being tablist data - a roughly O(n²) situation where each player stores entries for every other connected player. With ~800 players on a proxy, that alone accounted for nearly a gigabyte. Interesting, but that was a separate issue to log for later.

The more I poked around the heap dump, the more the voice chat rumour nagged at me. So I pulled up the Simple Voice Chat source and started reading the proxy code.

The vulnerable code

Simple Voice Chat's Velocity integration intercepts plugin messages flowing through the proxy via a PluginMessageEvent subscriber in SimpleVoiceChatVelocity. Any matching message gets forwarded to VoiceProxySniffer#onPluginMessage, which handles two channel types: voicechat:request_secret (sent client -> server when a player wants to set up voice) and voicechat:secret (sent server -> client to deliver the session secret back to the player).

The voicechat:secret handler looked like this (pre-fix):

// VoiceProxySniffer.java
public ByteBuffer onPluginMessage(String channel, ByteBuffer message, UUID playerUUID) {
    if (channel.equals(VoiceProxy.REQUEST_SECRET_CHANNEL) ...) {
        return handleRequestSecretPacket(message, playerUUID);
    }
    if (channel.equals(VoiceProxy.SECRET_CHANNEL) ...) {
        return handleSecretPacket(message, playerUUID);
    }
    return null;
}

Notice there's no check on where the packet came from. Both voicechat:request_secret and voicechat:secret are handled regardless of whether they originated from a client or a backend server.

Inside handleSecretPacket, the proxy reads a SniffedSecretPacket from the payload and stores the player UUID from inside it:

// VoiceProxySniffer.java (pre-fix), line ~109
playerUUIDMap.put(packet.getPlayerUUID(), playerUUID);

Where packet.getPlayerUUID() is a UUID embedded in the packet payload - entirely attacker-controlled. The value stored (playerUUID) is the proxy-side UUID of the player whose connection delivered the packet.

So the map's keys are UUIDs pulled from packet contents, not from any authenticated source.

Now for the cleanup side. When a player disconnects, onPlayerServerDisconnect is called with the player's proxy UUID. The cleanup looks something like:

playerUUIDMap.remove(proxyPlayerUUID);

But the key that gets removed, proxyPlayerUUID, is the actual UUID of the player, not the many UUIDs we are able to insert into this map using the exploit. So any entries that were injected by a malicious client will never be removed, the disconnect handler simply can't reach them.

The result: any connected player can flood voicechat:secret plugin messages with random UUIDs embedded in the payload, and each one permanently adds an entry to playerUUIDMap. The map grows without bound until the JVM runs out of heap.

Confirming it: server-side PoC

Before writing any exploit code, I wanted to confirm the bug path with a controlled test. I wrote a small Velocity plugin that used reflection to get hold of the VoiceProxySniffer instance and its playerUUIDMap directly, then called handleSecretPacket the same way an incoming plugin message would:

Field snifferField = voicechatPlugin.getClass().getSuperclass().getDeclaredField("voiceProxySniffer");
snifferField.setAccessible(true);
sniffer = snifferField.get(voicechatPlugin);

Field mapField = sniffer.getClass().getDeclaredField("playerUUIDMap");
mapField.setAccessible(true);
playerUUIDMap = (Map<?, ?>) mapField.get(sniffer);

With access to the sniffer, I could inject crafted packets in a tight loop via a /poc-oom <count> command, then check the results with /poc-oom status:

> poc-oom status
[16:41:31 INFO]: playerUUIDMap size: 20000 entries
[16:41:31 INFO]: Heap usage: 162 / 512 MB

I then simulated a player disconnect to demonstrate that the cleanup doesn't work:

Map size: 0 -> 20000 (delta: +20000 entries)
Now simulating player disconnect (onPlayerServerDisconnect)...
Map size after disconnect: 20000 (leaked 20000 entries!)

At a large enough injection count (~5,000,000 entries on a 256 MB proxy), the JVM hit its heap limit and the process was killed. Bug confirmed.

Completing the chain: client-side PoC mod

The server-side plugin proved the memory leak, but it required loading a plugin on the server - not how a real attacker would do it. To complete the picture I wrote a client-side Fabric mod that actually sends the malicious plugin messages over the network.

The key insight is that voicechat:secret is a server-to-client packet in the normal protocol flow. A client has no reason to send it. But Velocity's PluginMessageEvent doesn't distinguish direction, and neither does VoiceProxySniffer, so registering this channel as client-to-server in a mod and sending it works just fine:

// Register normally-S2C channel as C2S. Velocity doesn't check direction
PayloadTypeRegistry.playC2S().register(
    ExploitPayloads.SecretPayload.TYPE,
    ExploitPayloads.SecretPayload.CODEC
);

The payload format matches SniffedSecretPacket.fromBytes() exactly: 16 bytes of secret, a port, then the 16-byte UUID that will become the leaked map key, followed by codec, MTU, distance, keepAlive, groupsEnabled, voiceHost, and allowRecording fields. By generating a random UUID for each packet, every send leaks a fresh entry.

Velocity's packet rate limiter kicked in and capped throughput at around 400 packets per second before the connection got kicked. That sounds manageable, but at that rate a proxy with 256 MB of heap would OOM in roughly 4–7 hours. A larger heap just extends the timeline. Running the exploit from multiple accounts in parallel scales it proportionally.

After ~15 minutes of testing with a single account, the map was already at half a million entries:

> poc-oom status
[16:41:31 INFO]: playerUUIDMap size: 484200 entries
[16:41:31 INFO]: Heap usage: 198 / 256 MB

Reporting it

I opened issue #876 on the Simple Voice Chat repository. The report described the vulnerable code path, the cleanup bug, and the practical exploit rate, with a note that the attack works entirely over plugin messages - no access to the UDP voice chat port required.

The maintainer responded quickly and pushed a fix the same day: commit 18bd87e.

The fix

The fix is minimal and correct. VoiceProxySniffer#onPluginMessage gained a fromServer boolean parameter, and each channel is now only processed when it arrives from the expected direction:

// VoiceProxySniffer.java (after fix)
public ByteBuffer onPluginMessage(String channel, boolean fromServer, ByteBuffer message, UUID playerUUID) {
    if (!fromServer && (channel.equals(VoiceProxy.REQUEST_SECRET_CHANNEL) ...)) {
        return handleRequestSecretPacket(message, playerUUID);
    }
    if (fromServer && (channel.equals(VoiceProxy.SECRET_CHANNEL) ...)) {
        return handleSecretPacket(message, playerUUID);
    }
    return null;
}

In SimpleVoiceChatVelocity, the PluginMessageEvent handler was updated to derive fromServer from whether the event source is a Player (client-originated) or the target is a Player (server-originated):

// SimpleVoiceChatVelocity.java (after fix)
if (event.getSource() instanceof Player player) {
    p = player;
    fromServer = false;
} else if (event.getTarget() instanceof Player player) {
    p = player;
    fromServer = true;
} else {
    return;
}

The same fix was applied to the BungeeCord integration. A voicechat:secret message sent by a client now gets dropped before it ever reaches the map-writing code. The leak is closed.

A note on the original crashes

For completeness: this vulnerability was not what was causing the OOM kills on the network I was originally investigating. Those turned out to have a different root cause entirely. The voice chat issue was a separate find that came out of reading the code during that investigation. Still a real bug worth fixing - it just wasn't the bug I set out to find.

Takeaways

Read the source when you're already in there. I was reading heap dumps and server code anyway. Pulling up the voice chat source to check the rumour cost almost nothing. The bug was obvious once I was looking at it.

Separate "proves the memory leak" from "proves a client can trigger it". The server-side reflection plugin confirmed the bug path quickly and without needing to deal with network encoding. Once that was confirmed, writing the client-side mod to complete the attack chain was straightforward.

Direction-agnostic packet handling is a class of bug worth keeping in mind. Proxy plugins that intercept plugin messages often don't distinguish direction. For any channel that should only flow one way, processing it in both directions is a latent attack surface. The fix here is always simple - check the direction before acting on the contents.