Est. 2026

RelevantSec

2026-07-16

light
EN ES
research

RelevantSec Original Research

AnyDesk: First-Stage Goodware Analysis

TL;DR

We recently took a look at AnyDesk 9.7.10 and found that its first-stage executable uses several techniques we normally see during malware analysis:

  • PEB walking.
  • An empty import directory.
  • Encrypted API names.
  • Runtime export resolution.
  • A large encrypted and compressed PE image.
  • Pages made writable and executable.
  • An inner loader that applies relocations and repairs its own IAT.

The list could describe a packed implant, but here it belongs to a correctly signed remote-access product. This makes the file a useful malware-analysis exercise.

We will follow the small first stage until it recovers the 30 MB application hidden inside it. Along the way, we will introduce each concept when the loader needs it.

The full picture

When downloaded from the official AnyDesk Windows page, the signed executable is only about 8 MB. Its job is to reconstruct a much larger inner PE. At a high level, the startup path has two stages:

text
Signed AnyDesk.exe (8,343,992 bytes)
  |
  +-- Stage 1: PEB walk + EAT bootstrap
  |      |
  |      +-- LCG-XOR decrypts names and payload
  |      +-- LZMA decode + x86 BCJ reversal
  |      +-- copy headers and sections into reserved .itext
  |
  +-- Raw inner PE (30,489,600 bytes)
         |
         +-- Stage 2: relocations + imports + protections
         +-- enter the AnyDesk application

The stages divide the normal work of the Windows image loader:

Responsibility Outer stage Inner stage
Find a bootstrap module without imports Yes No
Resolve a small native API surface Yes Uses supplied callbacks
Decrypt and decompress the embedded PE Yes No
Copy PE headers and sections by RVA Yes No
Apply base relocations No Yes
Load 23 dependencies No Yes
Resolve 767 imports and repair the IAT No Yes
Set final per-section protections No Yes
Enter the real application No Yes

This is not classic Reflective DLL Injection because no remote process is involved. It is better described as a program manually rebuilding an image inside its own address space.

First look

Before opening a decompiler, we checked that we were all looking at the same sample:

Property Observed value
Product version AnyDesk 9.7.10
Architecture PE32 / x86
File size 8,343,992 bytes
SHA-256 46872febd9684df716d392b457aef6611ae7b8716d2ece6bca30fb97271bce1d
Authenticode status Valid
Signer AnyDesk Software GmbH
Certificate validity 11 February 2026 to 13 February 2027
Outer import directory Empty

Function names used below are labels we assigned during analysis; the binary is stripped. The assembly excerpts have also been cleaned up by removing sample-specific addresses while leaving the relevant instructions intact.

Using DiE, we can already see properties we would expect from a packer: an empty import directory, a high-entropy .data section, and an .itext section that occupies memory but has no bytes on disk.

DiE output of AnyDesk.exe
DiE output of AnyDesk.exe

High entropy alone cannot tell us whether data is compressed, encrypted, or simply unusual. It does tell us where to look next. In this sample, almost all of the file's high-entropy bytes sit in .data.

Entropy of each section in AnyDesk.exe
Entropy of each section in AnyDesk.exe

Given AnyDesk's functionality and the small amount of visible code, our working hypothesis is that .data contains the real application in some transformed form.

The section table supports that hypothesis:

Section Virtual size Raw size Characteristics
.text 0x296D 0x2A00 0x60000020
.itext 0x1D33C00 0 0xC0000080
.rdata 0x434 0x600 0x40000040
.data 0x7E8344 0x7E8000 0xC0000040
.rsrc 0x4878 0x4A00 0x40000040
.reloc 0x84 0x200 0x42000040

This gives us three useful observations:

  1. .text contains only about 10 KB of loader code.
  2. .data contains almost all the bytes that occupy disk space.
  3. .itext occupies no raw file bytes, yet asks the image mapping for 30,620,672 bytes of zero-backed virtual space.

The entry point sits inside the tiny .text section. From here, we can follow the loader in the same order it solves its own problems.

PEB walking

The first problem appears immediately: the executable has no normal imports. To understand why that matters, we need a short detour into how Windows normally connects a program to DLL functions.

Why the IAT matters

A regular PE contains an Import Directory. It tells Windows which DLLs the program needs and which functions it wants from each one. For every imported function, the loader resolves the real address and writes it into the Import Address Table, or IAT.

At source level, a program may contain:

c
VirtualProtect(address, size, PAGE_EXECUTE_READWRITE, &old_protection);

On 32-bit Windows, the compiled call often looks conceptually like this:

x86asm
call dword ptr [__imp__VirtualProtect@16]

__imp__VirtualProtect@16 is an IAT slot. Before the program starts, Windows replaces that slot with the real address of VirtualProtect inside kernel32.dll.

text
PE import descriptor
  -> "kernel32.dll"
  -> "VirtualProtect"
  -> Windows resolves the export
  -> Windows writes its address into FirstThunk / the IAT
  -> the program calls through that slot

In this AnyDesk file, the Import Directory is empty. There is no loader-populated slot for VirtualProtect, GetProcAddress, or even GetModuleHandleW. This first stage has no normal imported functions for Windows to bind.

This creates a bootstrap problem:

text
No imports -> no GetProcAddress
No GetProcAddress -> no easy way to resolve another API

The way out is to use information Windows has already placed in the process.

Following the PEB

Every thread has a Thread Environment Block, or TEB. Every process also has a Process Environment Block, or PEB. Among other things, the PEB points to loader data containing a linked list of the modules already loaded in the process.

On 32-bit Windows, the FS segment gives code access to the current TEB. AnyDesk's helper is only two instructions:

x86asm
mov eax, dword ptr fs:[0x18]  ; current TEB
ret

The caller follows the rest of the chain:

x86asm
call get_teb                 ; helper above
mov  eax, [eax+0x30]         ; TEB->ProcessEnvironmentBlock
mov  esi, [eax+0x0C]         ; PEB->Ldr
add  esi, 0x0C               ; &Ldr->InLoadOrderModuleList
mov  edi, [esi]              ; first LIST_ENTRY (Flink)

That is PEB walking in its simplest form: follow pointers through structures that Windows already initialized.

The same logic can be written in C-like code. This is deliberately 32-bit-specific and mirrors the offsets used by this sample:

c
static void *find_ntdll_base(void) {
    uint32_t teb  = __readfsdword(0x18);
    uint32_t peb  = *(uint32_t *)(teb + 0x30);
    uint32_t ldr  = *(uint32_t *)(peb + 0x0c);
    uint32_t head = ldr + 0x0c;

    // AnyDesk decrypts this value at runtime.
    wchar_t *wanted = decrypt_wide_name(
        encrypted_ntdll, 10, 0x3114b604);  // -> L"ntdll"

    for (uint32_t entry = *(uint32_t *)head;
         entry != head;
         entry = *(uint32_t *)entry) {

        wchar_t *base_name = *(wchar_t **)(entry + 0x30);

        // Five UTF-16 characters = 10 bytes.
        if (local_memcmp(base_name, wanted, 10) == 0)
            return (void *)*(uint32_t *)(entry + 0x18);
    }

    return NULL;
}

AnyDesk does not import memcmp for this either. The first stage contains its own small byte-comparison routine.

The relevant part of the real loop looks like this:

x86asm
module_loop:
push 0x0A                 ; decrypt 10 bytes
push encrypted_ntdll
push ntdll_seed
call decrypt_wide_name    ; produces L"ntdll"
push 0x0A                 ; comparison length
push eax                  ; decrypted name
push dword ptr [edi+0x30] ; BaseDllName.Buffer
call local_memcmp
test eax, eax
je   module_found
mov  edi, [edi]           ; entry = entry->Flink
cmp  edi, esi             ; back at list head?
jne  module_loop

module_found:
mov edi, [edi+0x18]       ; LDR entry -> DllBase

So the purpose is very concrete: obtain the base address of ntdll.dll without calling an imported API and without relying on an IAT.

If you want to explore the same structures interactively in WinDbg, the ired.team PEB walkthrough is an excellent companion. It shows the PEB, loader data, and module lists from the debugger's point of view.

The offsets above are not a portable Windows API. Microsoft treats these as internal structures, and the values differ between 32-bit and 64-bit processes. They are correct for this x86 sample and explain exactly what its instructions are doing.

Why this matters

Seeing fs:[0x18] alone is not enough to classify anything as malware. The important pattern is what happens next:

text
read TEB
  -> reach PEB loader data
  -> walk loaded modules
  -> recover ntdll base
  -> treat that base as a PE
  -> parse its exports

We now have the address of a DLL, but not yet the address of a function inside it. That leads naturally to the next step.

Resolving APIs

The opposite side of an import is an export. A DLL's Export Address Table (EAT) describes the functions that DLL makes available to other code.

Because AnyDesk cannot call GetProcAddress yet, the first stage performs the lookup itself.

Walking the EAT

Once edi contains the base of ntdll, the first stage parses it as a PE:

x86asm
mov eax, [edi+0x3C]       ; DOS.e_lfanew
mov eax, [eax+edi+0x78]   ; Export Directory RVA
add eax, edi              ; IMAGE_EXPORT_DIRECTORY *
mov ecx, [eax+0x1C]       ; AddressOfFunctions
mov edx, [eax+0x20]       ; AddressOfNames
mov ecx, [eax+0x24]       ; AddressOfNameOrdinals

It then walks the exported names until it finds LdrGetProcedureAddress. The target name is decrypted just before comparison:

x86asm
mov esi, [edx+ebp*4]      ; name RVA
add esi, edi              ; exported name
call decrypt_ascii_name   ; -> "LdrGetProcedureAddress"
call local_strcmp
...
movzx eax, word ptr [ordinals+ebp*2]
mov   eax, [functions+eax*4]
add   eax, edi            ; final function address

In cleaned-up C, the same lookup is easier to read:

c
static void *resolve_export(uint8_t *module, const char *wanted) {
    IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)module;
    IMAGE_NT_HEADERS32 *nt =
        (IMAGE_NT_HEADERS32 *)(module + dos->e_lfanew);

    DWORD export_rva =
        nt->OptionalHeader
          .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
          .VirtualAddress;

    IMAGE_EXPORT_DIRECTORY *exports =
        (IMAGE_EXPORT_DIRECTORY *)(module + export_rva);

    DWORD *names = (DWORD *)(module + exports->AddressOfNames);
    WORD *ordinals =
        (WORD *)(module + exports->AddressOfNameOrdinals);
    DWORD *functions =
        (DWORD *)(module + exports->AddressOfFunctions);

    for (DWORD i = 0; i < exports->NumberOfNames; i++) {
        char *name = (char *)(module + names[i]);
        if (local_strcmp(name, wanted) == 0) {
            WORD ordinal = ordinals[i];
            return module + functions[ordinal];
        }
    }

    return NULL;
}

Once LdrGetProcedureAddress is available, the bootstrap becomes much easier. AnyDesk uses it to resolve the rest of its small ntdll table, including LdrLoadDll, NtProtectVirtualMemory, and NtMapViewOfSection. It then obtains kernel32.dll and resolves functions such as HeapAlloc, GetProcessHeap, VirtualProtect, and GetModuleHandleW.

text
PEB walk
  -> ntdll base
  -> manual EAT lookup
  -> LdrGetProcedureAddress
  -> remaining ntdll functions
  -> LdrLoadDll / kernel32
  -> heap, command-line, module, and protection APIs

Encrypted names

It is tempting to call every resolver like this “API hashing,” but that would be wrong here.

API hashing stores a fixed integer for each desired function. The resolver hashes every exported name and compares integers. AnyDesk instead stores reversible encrypted bytes, decrypts them, and performs normal string comparisons.

We recovered, among others:

text
ntdll
kernel32
LdrGetProcedureAddress
LdrLoadDll
NtProtectVirtualMemory
HeapAlloc
VirtualProtect
GetModuleHandleW

The distinction matters during analysis. Hashing asks us to reproduce a hash algorithm. This sample asks us to reproduce a keystream.

Decrypting data

The name-decryption helpers and the large payload use the same basic construction: a linear congruential generator, or LCG, produces one key byte at a time, and that byte is XORed with the ciphertext.

text
state = state * 0x0019660D + 0x3C6EF35F
key   = (state >> 12) & 0xFF
plain = cipher XOR key

The state update is deterministic and each seed is present in the first-stage code.

The payload loop

This is the complete payload-decryption loop from the sample, with addresses replaced by labels:

x86asm
push esi
mov  edx, data_section      ; start of .data in memory
mov  ecx, 0x0000727F        ; seed
mov  esi, encrypted_size    ; encrypted byte count

decrypt_loop:
imul ecx, ecx, 0x0019660D
add  ecx, 0x3C6EF35F
mov  eax, ecx
shr  eax, 0x0C
xor  byte ptr [edx], al
inc  edx
sub  esi, 1
jne  decrypt_loop
pop  esi
ret

At a higher level it would be expressed like this:

c
static void decrypt_payload(uint8_t *data) {
    uint32_t state = 0x727f;

    for (uint32_t i = 0; i < 0x7e7f32; i++) {
        state = state * 0x0019660d + 0x3c6ef35f;
        data[i] ^= (uint8_t)(state >> 12);
    }
}

This is obfuscation, not secure cryptography. The algorithm, seed, and ciphertext are all present in the file, and there is no authentication tag. Still, it achieves several useful effects for a packer: strings disappear, the payload no longer has a recognizable header, and basic import or signature scans reveal little about the real application.

Decompressing data

Decrypting the blob only exposes the next layer: a compressed stream.

AnyDesk uses LZMA (Lempel-Ziv-Markov chain Algorithm), the compression algorithm best known from 7-Zip. LZMA combines dictionary matching with range coding. It is slower and more memory-hungry than simpler formats, but it often produces a high compression ratio, which is useful when a vendor wants to ship a large application inside a much smaller wrapper.

This is a raw LZMA1 stream rather than a .7z or .xz container. A container would normally carry descriptive metadata for us. A raw decoder instead needs the LZMA model properties, dictionary size, compressed input, and expected output size. In this sample, the stream has no end marker, so the known output size also tells the decoder when to stop.

How we know the properties

After the XOR loop, the decrypted .data begins with:

text
5D 00 00 00 04

Those five bytes use the layout expected by the LZMA SDK:

  1. The first byte packs lc, lp, and pb:

    text
    property = (pb * 5 + lp) * 9 + lc
    0x5D     = (2  * 5 + 0)  * 9 + 3

    lc controls how many high bits of the previous literal provide context, lp adds low position bits to the literal context, and pb controls the position model used for matches.

  2. The next four bytes are a little-endian dictionary size. Here, 00 00 00 04 means 64 MiB.

Field Value
lc, lp, pb 3, 0, 2
Dictionary size 64 MiB
Compressed bytes after properties 0x7E7F2D
Expected output size 0x1D13C00 (30,489,600 bytes)

These values are not a guess based only on the bytes. The first-stage code decrypts the payload, treats its first five bytes as a properties block, passes the remaining bytes to its LZMA decoder, and allocates the expected decoded size. The standard property layout, the caller's arguments, and the successful 30,489,600-byte decode all agree.

The first-stage orchestration shows the order directly:

x86asm
call decrypt_payload
mov  ebx, decoded_size
call heap_alloc
...
call lzma_decode
...
push esi                 ; decoded buffer
call x86_bcj_convert
                         ; raw PE is ready after this returns

The BCJ step

LZMA output is not quite final. AnyDesk also uses the standard x86 BCJ branch converter.

Near x86 CALL and JMP instructions, usually identified by opcodes E8 and E9, store a signed 32-bit displacement relative to the end of the instruction:

text
target = address_after_instruction + relative_displacement
relative_displacement = target - address_after_instruction

That representation is convenient for execution but awkward for compression. Two call sites that reach the same function usually contain different displacement bytes because the call instructions sit at different positions. LZMA therefore sees fewer repeated byte sequences than the source code's structure would suggest.

Before compression, the BCJ encoder scans for eligible E8 and E9 instructions and converts their relative operands into position-normalized values. Conceptually:

text
packing:   normalized_operand = relative_displacement + position_after_instruction
unpacking: relative_displacement = normalized_operand - position_after_instruction

The real converter also checks operand ranges and high bytes so it does not blindly rewrite every E8 or E9 byte it encounters. The normalized values make branches to related targets look more alike, which gives LZMA better matches. BCJ does not compress anything by itself and it does not provide confidentiality; it is a reversible pre-processing filter.

After LZMA decoding, those normalized operands still need to be converted back or the program's control flow would be wrong. The routine in this sample matches the LZMA SDK's x86_Convert design and is called with its encoding flag set to zero, meaning decode.

Getting the real PE

There are two practical ways to recover the inner executable.

The static route follows the transformations we just identified:

text
encrypted payload bytes from .data
  -> LCG-XOR with seed 0x727F
  -> read the five-byte LZMA properties block
  -> raw LZMA decode to 0x1D13C00 bytes
  -> x86 BCJ reversal
  -> raw PE32 file

The dynamic route is shorter. A debugger can stop immediately after the BCJ routine returns and dump the completed output buffer from memory. That recovers the same file. We mention this as an alternative verification route without turning the article into a step-by-step dumping guide.

The recovered file verifies as:

Property Value
Size 30,489,600 bytes
MD5 c280dca9bf3927c8238854cf9dbc62e3
SHA-1 5840c5218c0c31d82b1b4308a0979f384b76b75c
SHA-256 4117961150ab001e524a39be50250d221662f02b302c98e94248fc6ec450f282

Mapping the PE

Recovering a PE file and making it runnable are different tasks. A file stores sections according to PointerToRawData; a mapped image places them at their VirtualAddress.

The outer executable already reserved a large zero-backed .itext range. Stage one changes that range to PAGE_EXECUTE_READWRITE, validates the recovered headers, copies the PE headers, and then copies each section into its virtual position.

The actual mapper makes the transition visible:

x86asm
push 0x40                  ; PAGE_EXECUTE_READWRITE
push dword ptr [ebp+0x1C]  ; reserved range size
...
push edi                   ; .itext destination
call eax                   ; resolved VirtualProtect

mov  eax, 0x5A4D
cmp  word ptr [edx], ax       ; "MZ"
mov  ecx, [edx+0x3C]          ; e_lfanew
add  ecx, edx                 ; NT headers
cmp  dword ptr [ecx], 0x4550  ; "PE\0\0"

push eax                   ; SizeOfHeaders
push edx                   ; raw PE
push edi                   ; mapped destination
call local_memcpy

push dword ptr [edi-0x04]  ; SizeOfRawData
mov  eax, [esi+0x0C]       ; raw PE base
add  eax, [edi]            ; + PointerToRawData
push eax                   ; source
mov  eax, [edi-0x08]       ; VirtualAddress
add  eax, [esi+0x20]       ; + mapped base
push eax                   ; destination
call local_memcpy

At the end of this function, .itext looks like a mapped PE, but it is not ready to run. Stage one has not yet:

  • applied base relocations;
  • loaded the inner image's dependencies;
  • resolved its imports;
  • filled its IAT; or
  • applied final per-section protections.

That work belongs to the loader carried inside the recovered PE.

The handoff

The inner image exports three useful names:

Export Role
loader_main_thunk Bridge to application startup
ldr_thunk_data Shared loader context
loader_entry_thread_thunk Entry into the inner loader

Stage one parses the inner EAT to find ldr_thunk_data. It fills that structure with the mapped base, command line, status fields, page-protection data, and the native function pointers it resolved earlier. It then resolves and directly calls loader_entry_thread_thunk.

Stage two

The inner loader now finishes the jobs normally performed by the Windows PE loader.

Relocations

The recovered image records a preferred base in its PE headers, but stage one placed it inside the outer image's .itext range. Stage two calculates the difference and walks the Base Relocation Directory, applying x86 IMAGE_REL_BASED_HIGHLOW fixes.

Without this step, absolute addresses compiled for the preferred base would point to the wrong memory.

Import repair

Unlike the outer wrapper, the inner PE has a normal import directory: 23 DLLs and 767 imported symbols.

For each import descriptor, stage two:

  1. Loads or locates the requested DLL with the supplied LdrLoadDll callback.
  2. Reads each requested name or ordinal from the lookup thunk.
  3. Resolves it with LdrGetProcedureAddress.
  4. Writes the resulting address into FirstThunk, which is the IAT.

This is the same binding Windows usually performs before an executable starts. The difference is that AnyDesk's inner loader is doing it manually.

Stage one reads EATs to bootstrap itself. Stage two writes the inner image's IAT so its normal call sites work.

Final protections

The all-RWX .itext range is useful while the image is being constructed. Stage two eventually converts PE section flags into normal page protections: executable/readable code, read-only data, and readable/writable data.

Why do this?

Static analysis can show what the design achieves, but not which requirement the developers considered most important.

The visible results are straightforward:

  • The 30.49 MB inner image ships inside an 8.34 MB outer executable.
  • AnyDesk remains a single portable file.
  • The vendor controls the transition from container to application.
  • Shallow static analysis sees a tiny loader instead of the full program.

Those are normal product-engineering benefits with side effects that resemble malware evasion. Both statements can be true at once.

Conclusion

The outer AnyDesk executable is a container and bootstrap loader rather than the full application.

It starts with no normal imports, so it walks the PEB to find ntdll. It parses the EAT to resolve its first loader API, builds the rest of its API table, decrypts the embedded .data stream with LCG-XOR, expands it with LZMA, reverses the x86 BCJ filter, and maps the resulting PE into .itext.

The inner loader then applies relocations, resolves 767 imports from 23 DLLs, sets final protections, and enters the real application.

That sequence looks familiar because malware uses the same building blocks. The useful skill is not memorizing which techniques are "bad". It is being able to explain what each instruction achieves, how one stage creates the conditions for the next, and what the surrounding evidence says about the file.

References

  1. ired.team, Exploring Process Environment Block.
  2. Microsoft, PE Format.
  3. Microsoft, PEB structure and PEB_LDR_DATA structure.
  4. Microsoft, Understanding Executable File Signing.
  5. 7-Zip, LZMA SDK and x86 BCJ converter.
  6. AnyDesk, Windows download page and Windows changelog.
  7. MITRE ATT&CK, Software Packing (T1027.002), Dynamic API Resolution (T1027.007), Embedded Payloads (T1027.009), and Encrypted/Encoded File (T1027.013).