The loader stub and the runtime boundary
The file boundary is blunt. A protected script checks for the ionCube Loader extension before it does anything useful, and it will bail out if the extension is missing. In practice that means the protected file is not self-contained code, but a container that expects a specific Zend Engine hook to be in place.
The loader itself is a native zend_extension shared object, such as ioncube_loader_lin_8.5.so on aarch64 Linux. Once loaded, it plugs into PHP startup and module handling through the normal Zend lifecycle, which is where the hand-off becomes interesting from an analysis point of view. If the extension never loads, the protected payload never reaches the execution path that turns it into PHP bytecode.
Find the zend_extension entrypoint before the file runs
The first useful target is the exported zend_extension_entry symbol. That is the obvious entrypoint for the loader, and it gives a clean path into the rest of the module initialisation flow, including the usual MINIT, MSHUTDOWN, RINIT, RSHUTDOWN, and MINFO handlers. From there, execution reaches the code that decides whether a file is protected, whether it can be opened, and whether the loader has enough key material to continue.
Protected files do not fail quietly. The loader can emit messages such as The file '... is corrupted.orno decryption key available`, which is useful because it confirms where the process stopped. If the key sources do not resolve, the loader gives up before any decrypted opcodes are handed to Zend VM. That is a hard boundary, not a suggestion.
Trace the hand-off into Zend VM bytecode
The payload format starts with a container header that carries lengths, a format or method identifier, and seed material. The first layer uses a custom base64 alphabet, with characters ordered 0-9, then A-Z, then a-z, then +/. After that, the loader parses nested containers and derives per-file keys before it touches the stream cipher.
The bytecode does not appear in the clear. The loader decrypts the blob into Zend VM bytecode, then passes it into the runtime path that deals with op arrays and execution. In the analysed path, the loader handled the modern opline format used by current PHP builds, which is why static unpacking has to follow the exact container structure and the exact key derivation path before the result makes sense.
Five per-file key sources were present in the analysed loader: an embedded constant, a PHP variable, function bytecode, a disk file, and a name-mangling source. The embedded constant was 16 bytes stored as four u32 words. MurmurHash3-32 with a seed constant of 0x1f fed part of the derivation, and the stream cipher used a dual 16-bit MWC generator with multipliers 18000 and 30345. The keystream byte came from (prng.next() >> 8) & 0xff, which is the sort of detail that turns a near-match into complete gibberish if it is off by one bit.
Static unpacking works only when all of that is reproduced exactly. A naïve joaat port can fail if bytes are treated as signed, and the wrong seed state produces the wrong keystream immediately. The loader also carries a statically linked Zend VM, a Reflection API reimplementation, custom deserialisation logic, and crypto code from LibTomCrypt, which makes dynamic unpacking awkward enough without pretending the file format is forgiving.

