The encoded stub gives away the container before the cipher does
The stub is not plain base64. It uses a custom alphabet, then nests payloads inside payloads, which is awkward if you expect a single clean decode and a neat handoff to the Zend VM. The format marker in the header decides how the rest is parsed, including the length fields and the seed material used later in decryption.
That header matters more than it looks. It sets the structure of the container, tells the loader how much data to read, and steers the byte format that follows. Once the format is known, the rest of the unpacking path stops looking like mystery meat and starts looking like a fixed parser with a cipher bolted on.
Custom base64 alphabet and nested payloads
The encoded stub does not rely on the usual alphabet order. That alone blocks the lazy decode-and-dump approach, since the stream has to be translated before anything sensible appears. Nested containers add another layer, because the visible payload is not always the one that holds the protected PHP bytecode.
This is the sort of thing that breaks tools which assume a single wrapper and a single decode step. If the outer container is wrong, the output looks like nonsense and the next stage never gets a clean input.
Header fields that decide format, length, and seed material
The header carries the pieces the loader needs before it touches the payload. Format identifier, length information, and seed material all sit there. Those fields are not decorative. They decide which path the loader takes and which decryption routine gets fed with which inputs.
A wrong header parse gives a wrong seed, and the rest of the byte stream stays useless. That is the usual failure mode when a parser gets the structure right but the field sizes wrong.
The loader’s key path is easier to break than the byte stream
The byte stream looks harder than it is. The loader’s key derivation path does more of the work than the cipher itself, which is why evaluation files are awkward in one sense and convenient in another. For unlicensed or evaluation files, the four embedded key words are zeroed, so the constant-key path collapses into something far more predictable.
One exported helper at 0x4446f0, named mgniyd, handles part of this key derivation. Other paths use a designated function result, then hash that output. In practice, the loader can pull from five per-file sources, so the key is not always stored in one obvious place. When those sources fail, the loader gives up with the familiar “no decryption key available” path.
Zeroed embedded words in evaluation files
The evaluation case is blunt. The four embedded key words are all zero, which removes most of the guesswork from that branch. That makes offline handling much easier, because the constant-key path no longer depends on hidden per-file values.
It also means the loader’s own structure leaks just enough to help. The key is not magically absent. It is just made boring.
Function-driven derivation and the exported mgniyd path
Some files derive the key by running a function and hashing its result. That is awkward for dynamic unpacking, because the loader has a reimplementation of Reflection and a custom deserialisation path, so normal introspection can be clumsy. The exported mgniyd path sits there as a clearer target when the function-driven route is in play.
The important detail is not the name. It is the behaviour. The loader executes a per-file derivation step before it starts streaming bytes through the cipher, and that step can be reproduced offline if the inputs are known.
PRNG seeding with joaat and MurmurHash3-32
The stream cipher does not use a static byte map. It seeds a PRNG from hash output, with joaat and MurmurHash3-32 in the chain. One seed source uses 0x1f as the MurmurHash3-32 seed, which is a small detail with a large effect if the implementation is wrong by even one byte.
The PRNG itself is a dual 16-bit MWC with multipliers 18000 and 30345. Its output word is formed as ror32(y, 16) + x, and the keystream byte comes from ((prng.next() >> 8) & 0xff). A bad hash port, or a sign-extension mistake in joaat, gives the wrong seed and the wrong stream. The output still looks structured, which is the annoying part.
Pulling usable PHP bytecode out of the stream
The decrypted output maps back to Zend VM op_array structure, which is the useful point. At that stage the payload is no longer “protected PHP” in any meaningful sense. It is opcode data that the loader can hand to its own statically linked Zend VM and execute.
The bytecode format in the analysed path matches modern opline layout, so a dump has to be checked against current opcode fields rather than some old approximate structure. If the mapping is off, the output may still look plausible while being structurally wrong. That is how people waste an afternoon.
Mapping decrypted output back to Zend VM op_array structure
The decrypted stream holds serialised opcode data that lines up with op_array. That means the key is not just getting bytes back, but getting them back in a form that matches Zend’s expectations. Once the structure is clear, the opcodes, literals, and control flow become readable enough for analysis.
The loader’s own execution path confirms that the target is not a custom script format. It is PHP bytecode in the sense that matters here, just wrapped, seeded, and streamed in a way that tries to keep the obvious tools out.
Checking the dump against modern opline format
Modern opline format needs the right field layout. Older assumptions about opcode records or operand positions can spoil the whole dump even when the decryption itself is fine. The check is boring but necessary: the structure has to fit the VM, not the reverse engineer’s favourite parser.
A clean dump should survive that comparison without special pleading. If it does not, the failure is usually in the decode path or the byte ordering, not in the bytecode itself.
What fails when the assumptions are wrong
The loader is forgiving only in the sense that it exits quickly. If the zend_extension is missing, it stops before the protected payload gets anywhere near execution. That is a hard boundary, not a hint.
Other failures are subtler. A wrong joaat port can sign-extend bytes and poison the seed, which then poisons the whole stream. The output still changes, but not in a way that tells you where you went wrong unless you already know the hash path.
Missing zend_extension and the early exit path
Without ionCube Loader installed as a zend_extension, the stub checks fail early. The protected script never reaches the useful part of the pipeline. The loader needs to be present because it does the decoding and the execution, not just the checking.
That early exit is common enough to matter when testing on a clean machine or in a container. If the extension is absent, there is no offline trick inside the running script itself.
Sign-extension errors that spoil the seed
A naive joaat implementation is an easy way to get a convincing but wrong result. Treating bytes as signed instead of unsigned changes the hash, which changes the PRNG seed, which changes every decrypted byte after that. The stream will still be the right length, which makes the mistake look annoyingly plausible.
This is the sort of bug that hides in plain sight. The code runs, the output exists, and nothing useful is in it.
Reimplementation quirks in Reflection and deserialisation
ionCube Loader includes a reimplementation of PHP Reflection and a custom deserialisation pipeline. That makes some dynamic extraction paths clumsy, because the usual PHP inspection hooks do not behave exactly as expected. It also means any offline tooling has to match the loader’s quirks, not PHP’s idealised behaviour.
That is often where the work stalls. The cipher is not the only thing resisting analysis. The loader’s own interpreter pieces are doing a fair bit of the obstruction for free.

