Back to blogs

Research Report

Isaeva

Website
Discord
Telegram
X

Isaeva is a Roblox executor. Volt replaced Hyperion's module. Isaeva leaves Hyperion loaded and injects its own DLL into Roblox instead.

I only covered Isaeva's interesting aspects. Plenty of what Isaeva does goes beyond the scope of this writeup and is intentionally left out as an exercise for the reader.

I had both binaries. DLL first.

1. The DLL

DLL entry point

EP.

Process attach forwards into init.

Module initialisation path

The first Isaeva specific thing is the mutex (another detection vector):

1Local\\Isaeva.Module.%lu

Perproc mutex and thread creation

PID goes into the mutex name, then _beginthreadex starts another thread.

That thread starts by decoding a block of strings with XMM XORs, i recommend using the plugin by Sam Tulach - https://github.com/SamuelTulach/unxorer/releases/tag/v6 its great!.

SIMD XOR string decoding

A few of the decoded strings name the init paths directly:

1Relay Initialization Started
2Relay Worker Started
3Scheduler Initialization Started
4Scheduler Initialization Complete

Relay and scheduler startup

I used those names for the relay and scheduler branches. The smaller calls are still unnamed.

The scheduler branch is even nicer because it leaves its own function name and both sides of the hook result:

1SchedulerInstance::Initialize
2Hooked FLog::LogCallback @ %p w/ %o
3Log hook unavailable

Scheduler initialisation

Follow that branch and the hook is right underneath it.

FLog callback hook path

FLog::LogCallback hook path.

I am not that deep into Roblox, and I did not spend much time on Hyperion / Byfron for this one, so I have no good answer for why Isaeva wants FLog::LogCallback. The screenshots prove the hook is there. I am not going to invent the reason for it.

One resolver near the same code is just the Roblox image base plus an RVA.

Host module relative target resolution

GetModuleHandleA(NULL) + 0x7E73AB8 in this build.

The RVA in my sample is 0x7E73AB8. Roblox moves enough between client builds that I would already treat this as outdated and rederive it instead of copying it.

That is as far as this screenshot set goes on the DLL side.

2. The injector

The injector has lots of imports, take them to ur advantage.

Injector entry and imports

IAT.

OpenProcess is one of the few imports worth following.

OpenProcess reference

Opening the target process

it opens a target process

From there the same path starts preparing memory in Roblox. It reserves remote memory, fills it from local buffers and changes protection on one of the remote ranges.

Remote allocation

Remote memory protection and writes

There are several writes around this code.

There is no CreateRemoteThread. The injector starts looking through Roblox's handles instead.

3. The handle walk

Candidate handles are duplicated out of Roblox and into the injector.

Duplicating target handles

Candidate handle duplicated with DuplicateHandle.

The handle list comes from NtQueryInformationProcess with ProcessHandleInformation.

Querying process handles

Every candidate then goes through NtQueryObject.

Querying duplicated object types

NtQueryObject with ObjectTypeInformation.

It keeps walking until the object type is IoCompletion. There is no fixed handle value to carry between runs. The injector finds Roblox's IOCP again every time.

The IOCP handle is what the last part of the injector needs.

4. PoolParty

Once the IOCP is found, one of those remote writes finally has a name.

Writing TP_DIRECT into the target

Local TP_DIRECT built and copied into Roblox.

The write is 0x48 bytes:

1nSize = 0x48

The local buffer is cleared first, its callback field gets the remote exec addr, then the whole structure is copied into Roblox.

The next screenshot is the queue operation.

Queueing the remote TP_DIRECT

Duplicated IOCP handle + remote TP_DIRECT.

This is PoolParty Variant 7, Remote TP_DIRECT Insertion.

SafeBreach's Variant 7 does the same thing: place a TP_DIRECT in the remote proc and insert it directly into the target thread pool's I/O completion queue with NtSetIoCompletion.

SafeBreach, Process Injection Using Windows Thread Pools

The path in this sample ends up as:

 1OpenProcess
 2    |
 3    +-> remote memory setup
 4    |
 5    +-> NtQueryInformationProcess(ProcessHandleInformation)
 6    |       |
 7    |       +-> DuplicateHandle
 8    |               |
 9    |               +-> NtQueryObject(ObjectTypeInformation)
10    |                       |
11    |                       +-> IOCP
12    |
13    +-> write remote TP_DIRECT
14    |       |
15    |       +-> callback = remote exec addr
16    |
17    +-> NtSetIoCompletion(IOCP, remote TP_DIRECT, ...)
18            |
19            +-> Roblox thread pool reaches callback

No remote thread is created. The completion gets handled by Roblox's existing thread pool.

Elastic documented the same Variant 7 primitive in GOSAR: recover the target IOCP, put code and a crafted TP_DIRECT in the remote proc, then queue it with ZwSetIoCompletion.

Elastic, Under the SADBRIDGE with GOSAR

5. Hyperion

Isaeva leaves Hyperion loaded. PoolParty explains how this injector gets Isaeva code running in Roblox, but the screenshots stop before the Hyperion-specific part of the DLL.

6. If I were on the other side

The first thing I would score is an unsigned / unknown proc holding a Roblox handle with PROCESS_VM_WRITE, PROCESS_VM_OPERATION or PROCESS_DUP_HANDLE. I would personally flag it from the handle alone, as theres no reason from unsigned process to open handle to roblox - your process.

For memory, if you own the target, I would keep the bookkeeping myself instead of trying to guess later.

Wrap your own NtAllocateVirtualMemory, NtProtectVirtualMemory and NtFreeVirtualMemory paths and keep a small live list of what the program did:

1base -> {
2    size,
3    protect,
4    state
5}

Allocate a region, add it. Reprotect it, update it. Free it, remove it.

Then an occasional VirtualQueryEx / NtQueryVirtualMemory walk can be compared against the map you already trust. You are not trying to remember every byte, only which regions should exist and which protection they are supposed to have.

If something executable appears that none of your own allocation paths created, or a region becomes executable without your protection wrapper recording it, that is worth looking at.

This is pretty nice when you control the target and know what it is supposed to allocate. It is not some universal anti injection check. JIT makes it annoying because legitimate runtime-generated code also creates private executable memory and changes page protection, so now your expected map needs to account for that too.

Zypherion already deals with this class of handle hijacking / duplication and remote memory abuse.

There are a few other vectors here too, but I am leaving those out,as owners of cheats can just read them and adapt.

If you are on the Roblox / Hyperion side and want to go over the rest, reach out through any of the links above. Same if you want reversing / research commissioned.