~/posts/binary/x86-shellcode-ror13-resolver-assumptions.md

x86 shellcode: hidden assumptions in a ROR13 resolver

Recover nine API constants through call/pop, PEB list offsets, and PE exports. Check signed byte normalization, capacity-dependent hashes, collisions, standard-handle relationships, and exit conditions.

date[31:24]
read[23:16]
8 min
cat[15:8]
Binary
Contents
  1. 0x00Find the shared resolver through call / pop
  2. 0x01A list node is not the start of its container
  3. 0x02A signed branch comes before ROR13
  4. 0x03Export names, ordinals, and addresses are separate lookups
  5. 0x04Recover nine API constants with an offline model
  6. 0x05Recover handle relationships from the API sequence
  7. 0x06An equal hash is not an equal module identity
  8. 0x07Separate observer setup from sample execution
  9. 0x08Official references

Raw x86 shellcode has no conventional PE loader to populate its imports. Recovering its shared API resolver before following arguments and handles is more useful than translating every instruction. The same analysis also exposes assumptions the resolver makes about its host process.

The subject is a 324-byte TCP reverse-shell sample associated with Metasploit v6.0.30-dev, recorded with SHA-256 3792f355d1266459ed7c5615dac62c3a5aa63cf9e2c3c0f4ba036e6728763903. Offsets below are relative to the raw byte stream, not runtime virtual addresses. Offline hash checks neither execute the sample nor independently verify that recorded file digest.

Find the shared resolver through call / pop

entry / Main
0x0000fccld
0x0001e8 82 00 00 00call0x88
0x00885dpopebp
0x009468 4c 77 26 07push0x0726774c
0x0099ff d5callebp

These are noncontiguous instruction excerpts. The initial call 0x88 pushes return position 0x06, which Main retrieves with pop ebp. Later call ebp instructions return to the resolver entry. cld makes subsequent string instructions advance through increasing addresses.

Apart from the initial call, the sites at 0x99, 0xA9, 0xB8, 0xD2, 0xE2, 0x115, 0x123, 0x12F, and 0x142 share this entry. Their preceding constants differ. Module traversal, export-table access, and hash comparisons turn the shared-resolver hypothesis into a structural conclusion.

A list node is not the start of its container

module-name input
0x000b64 8b 50 30movedx, dword ptr fs:[eax+0x30]
0x000f8b 52 0cmovedx, [edx+0x0c]
0x00128b 52 14movedx, [edx+0x14]
0x00158b 72 28movesi, [edx+0x28]
0x00180f b7 4a 26movzxecx, word ptr [edx+0x26]

EAX has already been cleared in this excerpt. fs:[0x30] provides the PEB, followed by Ldr and InMemoryOrderModuleList. EDX then points to the embedded InMemoryOrderLinks member rather than the start of the enclosing module record.

Relative offsets in the x86 sample5 rows
Field Container-relative offset List-node-relative offset
InMemoryOrderLinks 0x08 0x00
DllBase 0x18 0x10
BaseDllName.Length 0x2C 0x24
BaseDllName.MaximumLength 0x2E 0x26
BaseDllName.Buffer 0x30 0x28

The actual field read is MaximumLength. Microsoft defines both it and Length in bytes: one describes buffer capacity, the other string length. When a terminator is present, Length excludes it. Hashing capacity means identical visible text need not produce an identical hash. UNICODE_STRING

The modules form a doubly linked list. When iteration returns to its head, that head is not a module record. Continuing to interpret it as LDR_DATA_TABLE_ENTRY can produce invalid reads rather than merely an endless loop. These offsets belong to the particular x86 layout, not x64.

A signed branch comes before ROR13

module hash loop
0x001eaclodsb
0x001f3c 61cmpal, 0x61
0x00217c 02jl0x25
0x00232c 20subal, 0x20
0x0025c1 cf 0droredi, 13
0x002801 c7addedi, eax
0x002ae2 f2loop0x1e

Module names are UTF-16LE, but lodsb reads individual bytes. Ordinary ASCII DLL names therefore contribute alternating character and zero bytes. A zero byte adds nothing, but still causes a rotation.

Crucially, jl is a signed comparison. After cmp al, 0x61, only bytes in 0x61…0x7F enter the subtract-0x20 branch; 0x80…0xFF are interpreted as negative and skip it. This is neither Unicode case conversion nor an ASCII conversion restricted to a…z: 0x7B becomes 0x5B.

ROR13(0x0000004b)32-bit
B30x02B20x58B10x00B00x00
value0x02580000= 39321600
BitsFieldValue
31–24B30x02
23–16B20x58 (88)
15–8B10x00
7–0B00x00

The first K = 0x4B leaves a zero-initialized accumulator at 0x4B. Processing the following zero gives ROR13(0x4B) = 0x02580000. Each step retains 32 bits; the final module and export hashes are then added.

Export names, ordinals, and addresses are separate lookups

In a mapped PE image, module-relative +0x3C supplies e_lfanew. The PE32 export-directory RVA is at +0x78 from the resulting NT header. That offset and the frequently illustrated 0x60 refer to different bases: the latter is relative to the Optional Header.

Relevant IMAGE_EXPORT_DIRECTORY fields4 rows
Offset Field Role
0x18 NumberOfNames Number of name entries
0x20 AddressOfNames Array of name RVAs
0x24 AddressOfNameOrdinals Name index to function-table index
0x1C AddressOfFunctions Array of function RVAs

The sample walks names backwards, preserves export-name case, and includes the final NUL in ROR13. It compares (module_hash + export_hash) mod 2^32. On a match, it reads the 16-bit ordinal entry, then the 32-bit function RVA, then adds the module base. A name index is not a function address, and an RVA is not a disk offset. Microsoft PE format

resolver tail
0x007c59popecx
0x007d5apopedx
0x007e51pushecx
0x007fff e0jmpeax

After restoring registers, the resolver removes the caller's return address and hash, pushes the return address back, and jumps through EAX. The API can return to the original call site because this jmp preserves the required stack shape. Follow stack effects alongside control flow.

An export entry can also identify a forwarder string. The displayed path does not resolve forwarders and is not a complete Windows loader. Rapid7's official resolver source credits Stephen Fewer. Its current branch differs from this sample in length selection and hash composition, so it is a comparison reference, not a replacement for the observed bytes.

Recover nine API constants with an offline model

ror13-model.pypython
MASK = 0xffffffff
def ror13(x):
    return ((x >> 13) | (x << 19)) & MASK
def hash_bytes(data, module=False):
    value = 0
    for byte in data:
        if module and 0x61 <= byte < 0x80:
            byte = (byte - 0x20) & 0xff
        value = (ror13(value) + byte) & MASK
    return value
def module_hash(name):
    return hash_bytes((name + "\0").encode("utf-16le"), True)
def api_hash(dll, name):
    return (module_hash(dll) + hash_bytes(name.encode("ascii") + b"\0")) & MASK

assert module_hash("KERNEL32.DLL") == 0x92af16da
assert api_hash("KERNEL32.DLL", "LoadLibraryA") == 0x0726774c
assert api_hash("WS2_32.DLL", "WSAStartup") == 0x006b8029
assert api_hash("WS2_32.DLL", "WSASocketA") == 0xe0df0fea

The module_hash convenience function assumes an ASCII DLL name followed by one UTF-16LE NUL. If the actual allocation has additional capacity bytes, pass those exact bytes to hash_bytes instead of treating this convention as a loader guarantee.

The complete local checks cover nine call constants, length behavior, rotations, and byte order. Actual output follows:

ror13-resolver-model.py
$ python ror13-resolver-model.py
KERNEL32.DLL!LoadLibraryA=0x0726774c
WS2_32.DLL!WSAStartup=0x006b8029
WS2_32.DLL!WSASocketA=0xe0df0fea
WS2_32.DLL!connect=0x6174a599
KERNEL32.DLL!CreateProcessA=0x863fcc79
KERNEL32.DLL!WaitForSingleObject=0x601d8708
KERNEL32.DLL!ExitProcess=0x56a2b5f0
KERNEL32.DLL!GetVersion=0x9dbd95a6
NTDLL.DLL!RtlExitUserThread=0x6f721347
four_module_collisions=0x92af16da
maximum_length_tail_changes_hash=True
signed_jl_boundary_0x7b_0x80=PASS
ror13(0x4b)=0x02580000
ror26(0x4b)+0x45=0x00001305
sockaddr_example=AF_INET,4444,192.0.2.202
PASS: arithmetic only; no shellcode execution

A lookup table should retain every candidate for a hash and use arguments and context to disambiguate them. It is a reverse hash lookup table, not necessarily a rainbow table with a chain-based compression scheme.

Recover handle relationships from the API sequence

Main path and supporting evidence5 rows
Stage Identified APIs Key evidence
Initialization LoadLibraryA, WSAStartup, WSASocketA ws2_32, IPv4, SOCK_STREAM
Connection connect Stack sockaddr and a retry count of 5
Child creation CreateProcessA cmd, standard handles, inheritance flag
Waiting WaitForSingleObject Child process handle and an infinite timeout
Exit selection GetVersion, ExitProcess, RtlExitUserThread Version and exit-hash branches

The pushed string bytes are 77 73 32 5F 33 32 00 00, or ws2_32. An annotation reading ws3_32 conflicts with those bytes; the bytes decide the interpretation.

sockaddr_in
000000000200115CC00002CA
  1. AF_INET0x00–0x01
  2. port0x02–0x03
  3. IPv40x04–0x07

This is an address-substituted field example. The first two bytes decode little-endian as AF_INET = 2, the next two decode in network byte order as port 4444, and the address is 192.0.2.202. It demonstrates memory layout, not an observed connection to that example endpoint.

After a successful connection, the sample assigns the same socket to STARTUPINFOA's hStdInput, hStdOutput, and hStdError, sets STARTF_USESTDHANDLES and bInheritHandles = true, and creates cmd. These argument relationships support the remote-command-channel interpretation; connect alone does not. CreateProcessA

The exit tail includes version and hash-selection conditions. The low byte of the displayed ExitProcess constant does not satisfy the comparison that selects the other exit, so distinguish the presence of a RtlExitUserThread branch from this configuration actually taking it.

An equal hash is not an equal module identity

fixed-collision-check.pypython
names = ["KERNEL32.DLL", "HERNEL32.DLX", "IERNEL32.DLT", "JERNEL32.DLP"]
assert {module_hash(name) for name in names} == {0x92af16da}
raw = "KERNEL32.DLL\0".encode("utf-16le")
assert hash_bytes(raw, True) != hash_bytes(raw + b"\0", True)
assert hash_bytes(b"\x80", True) == 0x80
assert hash_bytes(b"\x7b", True) == 0x5b

All four module names produce 0x92AF16DA. Rotations and addition can overlap byte contributions: ROR26(0x4B) + 0x45 and ROR26(0x49) + 0xC5 both produce 0x1305. Validate full-name collisions with the byte model rather than inferring them solely from a local bit diagram.

A name collision alone does not redirect a call. The candidate module must also be present, expose the appropriate name, win the search order, and maintain the required calling convention. This concerns a particular shellcode's custom resolver, not a claim that the Windows loader identifies DLLs by this hash.

MaximumLength adds another dependency: spare capacity bytes can change the result without changing the visible name. Modifying loader structures also affects the host, bringing byte-count units, object lifetime, loader locks, concurrent enumeration, and ordinary name comparisons into the compatibility problem.

Separate observer setup from sample execution

Interface illustration of observer setup, a sample prompt, and a resolver alert

Interface illustration: observer setup, a 324-byte sample execution prompt, and a LoadLibraryA / ws2_32 alert.

The recorded order matters. The observation module is present first; the sample then begins resolving APIs and eventually reaches the observation function. This supports a path through one sample and process state, not coverage of every variant or compatibility of a deployable detection product.

The local work here consists of arithmetic and layout checks, not observation-DLL injection or reverse-shell execution. The reusable result is the resolver model: establish input bytes, pointer bases, termination conditions, and calling conventions, then connect the identified APIs to their actual arguments.

Official references

NORMAL~/posts/binary/x86-shellcode-ror13-resolver-assumptions.md§--
0%en