Techniques14-Jul-26|11 min read

Decode PHP Malware That Packs Its Strings into Integer Arrays

How PHP loaders hide their function and C2 tables as arrays of 32-bit integers, and how to read them back

A PHP file lands in your queue from a compromised shared-hosting account. You open it expecting a webshell and find something stranger: one array holding a couple of hundred hexadecimal integers, a short loop underneath it, and further down a scattering of references like $f[1] and $f[2]. No function names you recognize. No URLs. No configuration you can read. Dropped onto a server, though, this file forks itself into the background, renames its own process to resemble a kernel thread, and opens a socket to a remote host. The strings it needs are present in the file - they are just wearing an array of numbers as a disguise. This article takes the disguise apart: how the packing is built, and how to turn the integers back into the function names and endpoints an incident write-up actually needs.

A dense grid of hexadecimal integers on a dark background, part of it resolving into readable words, representing hidden strings being reconstructed from a packed array

Looking at a PHP loader that reads as a wall of integers? Paste it at klaroskope.com/submit and the packed table is rebuilt and its indicators surfaced, without executing the sample.

  • What a packed-integer string table looks like
  • How four chr() calls rebuild the original bytes
  • The null byte that turns one blob into a function map
  • The variable-function trick that hides the explode call
  • Where the C2 configuration sits, and how it is addressed by index
  • Why loader authors reach for this technique
  • Structural signs that you are looking at a packed table

What a packed-integer string table looks like

Most obfuscated PHP still leaves its strings recognizable once you squint: a chr() chain here, a hex escape there. Packed-integer tables take a different route. Rather than obscure each string on its own, the loader concatenates the strings it will use into one continuous run of bytes, separated by null bytes, and stores that run as an array of 32-bit integers. Four bytes to an integer. What you read in the file is arithmetic; what the loader assembles at runtime is a lookup table. A compact example, holding three function names and one piece of configuration, looks like this:

php
$sl = array(
  0x6578706c, 0x6f646500, 0x62617365, 0x36345f64,
  0x65636f64, 0x65006576, 0x616c0064, 0x5752774f,
  0x69387659, 0x7a49755a, 0x58686862, 0x5842735a,
  0x5335755a, 0x5851364f, 0x546b344f, 0x413d3d00
);
$r = "";
foreach ($sl as $d) {
  $r .= chr($d >> 24) . chr($d >> 16) . chr($d >> 8) . chr($d);
}

Four chr() calls rebuild the bytes

The loop is the whole mechanism. Each integer carries four bytes, most-significant first, and four chr() calls pull them back out in order. chr($d >> 24) shifts the top byte down into the low eight bits and turns it into a character; >> 16 recovers the next byte, >> 8 the third, and the bare $d the last. PHP's chr() keeps only the low eight bits of whatever value it is handed, so no explicit & 0xFF masking is needed - the high bits fall away on their own. Take the first integer, 0x6578706c. Its four bytes are 0x65, 0x78, 0x70, 0x6c, which are the characters e, x, p, l. The second integer continues with o, d, e, and a null byte. Word by word, the array rebuilds into this:

A diagram showing a row of 32-bit integers each splitting into four bytes, concatenating into a single byte run, then separating on null markers into a labelled table of PHP function names and a base64 value
text
explode\x00base64_decode\x00eval\x00dWRwOi8vYzIuZXhhbXBsZS5uZXQ6OTk4OA==\x00

One null byte, one function map

That reconstructed run is not a single string. The null bytes are separators, and splitting on them produces an ordered list. This is where the arithmetic pays off for the attacker: the same line that rebuilt the bytes also, by convention, feeds a split that turns the blob into a table the rest of the loader reads by position.

text
[0] explode
[1] base64_decode
[2] eval
[3] dWRwOi8vYzIuZXhhbXBsZS5uZXQ6OTk4OA==

The table mixes two kinds of entry. Some are the names of PHP builtins the loader intends to call - explode, base64_decode, eval, and in larger samples a longer roster that can include pcntl_fork, stream_socket_client, and fwrite. Others are configuration, stored as base64 so that even the reconstructed table does not show a readable URL.

The explode call that is not written

Here is the detail that makes this family worth its own write-up. The loader splits the blob on the null byte, but it does not write explode( in its source. It reconstructs the byte run, takes the first seven characters of that run - which spell the word explode, because the author arranged for it to be the first table entry - assigns those seven characters to a variable, and calls the variable as a function.

php
$f = substr($r, 0, 7);   // the first entry spells "explode"
$f = $f(chr(0), $r);     // this calls explode(chr(0), $r)

To a person reading the source, $f($x, $y) is just a variable being invoked. To a signature rule scanning for the literal text explode(, base64_decode(, or eval(, there is nothing to match, because those tokens are assembled at runtime and called indirectly. The pattern extends to each builtin the loader uses: it holds an index, not a name, so $f[1] stands in for base64_decode and $f[2] for eval throughout the file.

The C2 configuration lives at an index

Because the table is addressed by position, the network configuration needs no variable name of its own. It is simply another entry, and the loader reaches it by number. In the compact example, index 3 holds a base64 string. Running it back through the base64_decode the loader keeps at index 1 gives the endpoint:

php
$config = $f[1]($f[3]);   // base64_decode of the entry at index 3
// -> udp://c2.example.net:9988

Larger loaders carry several such entries: a first-stage HTTP check-in URL, a UDP beacon, a session filename, sometimes a small bootstrap that defines a helper for running received code. These entries arrive as base64 inside the same integer array, so recovering the array tends to recover the campaign's indicators in a single pass.

Why loader authors reach for this

The appeal is twofold. The first is evasion: static rules, hosting-provider scanners, and a hurried analyst all tend to key on readable tokens, and a file whose only readable content is hexadecimal gives them little to match. The second is a quieter form of self-protection. The author keeps their own build readable and ships only the packed form, so a competitor or a curious victim who finds the dropped file cannot easily lift its logic. Neither motive is specific to PHP, but PHP's chr() semantics and loose typing make the packed-array form tidy to write.

How to recognize a packed table

The technique leaves a distinctive shape even though it hides its strings. Watch for an array literal built almost entirely of eight-digit 0x values, dozens or hundreds of them, with little else inside the parentheses. Near it, look for a loop that applies chr() four times to the same variable with the shift amounts 24, 16, and 8, and once with no shift. A split on chr(0) usually follows, and after that the code starts addressing a variable by numeric index rather than by name. Any one of these can appear in benign code from time to time; together, in one short span, they are a reliable tell.

The reconstruction itself is safe to perform offline, and it should only ever be performed offline: the goal is to read the table, not to run the loader. Once rebuilt, the function-name entries describe the loader's capability at a glance - a table that contains pcntl_fork and stream_socket_client is signalling that it will daemonize and open a socket - and the base64 entries hand you the endpoints for blocking and hunting.

Where this shows up

Packed-integer loaders turn up most often on compromised CMS installations and shared hosting, dropped after an initial foothold rather than used to gain one. A recurring flourish is process-name camouflage: the loader retitles its own process to resemble a kernel worker thread, so an operator glancing at a process list sees something that looks like part of the operating system. In ATT&CK terms the packing maps to T1027, Obfuscated Files or Information, and the surrounding persistence to T1505.003, Web Shell. Treat any recovered endpoints as campaign indicators for blocking and retrospective hunting rather than as attribution, since the same packer is commonly reused across unrelated operators.

Rebuilding a packed table by hand is mechanical, and running the sample to shortcut it is off the table. KlaroSkope reconstructs the array statically and surfaces the recovered function names and endpoints. Paste a suspect PHP loader at klaroskope.com/submit.

Found this useful? Sharing is caring!

Ready to decode?

See KlaroSkope transform obfuscated scripts into actionable intelligence.

Try It Free