Deobfuscating a PHP backdoor step by step (safe and practical)
Practical (and safe) guide to identify, decode and remove common PHP backdoors — with neutral examples, Python deobfuscator and incident response checklist.
Security notice
Everything here is for defense. Examples are neutralized (don’t execute malicious payload) and the analysis script does not execute anything — it only decodes and prints for inspection.
TL;DR
PHP backdoors often hide a payload via base64 → XOR → (optional) compression and execute with eval/assert/preg_replace('/e'). You can: 1) Extract the blob and key
2) Decode it offline and safely
3) Remove and harden the environment (block execution in /uploads/, update, rotate credentials, WAF)
Common vectors
• outdated or nulled plugin/theme
• upload to /wp-content/uploads/ with execution enabled
• leaked FTP/panel credentials
• insecure includes (include($_GET['f']) etc.)
Quick indicators (IOCs)
• .php files with image names: image.php.jpg, favicon_abc.ico.php
• error suppression: @, ini_set('display_errors',0), error_reporting(0)
• indirect execution: eval, assert, create_function, preg_replace('/e')
• IP/User-Agent filters before execution
• “strange” timestamps in wp-includes/, wp-admin/, index.php
Stub anatomy (reconstructed and harmless example)
View PHP stub (safe)
<?php
/* payload stored in an "innocent" way */
$blob = 'U1dMQkQAAABfX0RVTU1ZUFJPVEVDSF8...'; // short, fake base64
/* simple key used by attacker */
$key = "k9";
/* common deobfuscation: base64 -> XOR -> (sometimes) zlib -> eval */
$data = base64_decode($blob);
/* XOR byte by byte (recurring pattern) */
$out = '';
for ($i = 0; $i < strlen($data); $i++) {
$out .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
}
/* In real samples we'd see something like: */
// eval($out);
/* Here, for safety, we only show the size */
echo "Decoded length: " . strlen($out);
Key points • the “poison” is hidden in data, not in clear code • the XOR key is usually short (1–8 bytes) • right after decoding comes execution (which we must avoid)
Python deobfuscator (safe, no eval)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import argparse, base64, binascii, zlib, sys
from pathlib import Path
def xor_bytes(data: bytes, key: bytes) -> bytes:
if not key:
return data
return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])
def maybe_uncompress(b: bytes) -> bytes:
try:
return zlib.decompress(b)
except Exception:
try:
return zlib.decompress(b, wbits=16+zlib.MAX_WBITS) # gzip
except Exception:
return b
def main():
ap = argparse.ArgumentParser(description="Decode PHP backdoor: base64 -> XOR -> (optional) zlib. No eval.")
ap.add_argument("-b", "--base64", help="base64 blob", required=False)
ap.add_argument("-f", "--file", help="file with base64 blob", required=False)
ap.add_argument("-k", "--key", help="XOR key (text)", default="")
ap.add_argument("--hexdump", action="store_true", help="show short hexdump")
args = ap.parse_args()
if not args.base64 and not args.file:
ap.error("provide --base64 or --file")
b64 = args.base64 or Path(args.file).read_text().strip()
try:
raw = base64.b64decode(b64, validate=True)
except binascii.Error as e:
print(f"[!] invalid base64: {e}")
sys.exit(1)
xored = xor_bytes(raw, args.key.encode())
dec = maybe_uncompress(xored)
print(f"[+] base64 bytes: {len(raw)}")
print(f"[+] after XOR: {len(xored)}")
print(f"[+] final: {len(dec)} bytes")
print("\n----- BEGIN OUTPUT (text preview) -----")
print(dec.decode("utf-8", errors="replace"))
print("----- END OUTPUT -----")
if args.hexdump:
print("\n[hexdump 64B]")
print(" ".join(f"{b:02x}" for b in dec[:64]))
if __name__ == "__main__":
main()
Quick usage
1
2
python3 deob.php.py -f blob.txt -k k9
python3 deob.php.py -b 'AAA...' -k secret --hexdump
Practical analysis workflow
- Backup/Snapshot the site before anything.
- Isolate the suspicious file; don’t run it on the server.
Extract the
blobandkey(regex helps):base64_decode\(\s*['"]([A-Za-z0-9+/=]+)['"]\s*\)\^|xorto find the key
- Decode offline with the script.
- Inspect output for behavior:
system/exec, exfiltration, webshell. - List IOCs: paths, parameters, C2 domains, hashes.
- Remediation: clean, rotate passwords/keys, update plugins/themes/core, apply WAF.
Before and after
1
2
3
<?php $a="U1dMQkQ...";$k="k9";$d=base64_decode($a);
for($i=0,$o='';$i<strlen($d);$i++){$o.=chr(ord($d[$i])^ord($k[$i%strlen($k)]));}
@assert($o); // in real samples
1
2
3
/* reconstructed and harmless example */
$cmd = $_POST['cmd'] ?? null;
if ($cmd === 'ping') { echo "pong"; }
Source code detection (grep/regex)
Useful searches:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# dangerous functions
rg -n "eval\s*\(|assert\s*\(|create_function\s*\(|preg_replace\s*\(.*\/e" -S
# base64 + decode
rg -n "base64_decode\s*\("
# patterns with gzinflate/rot13
rg -n "(gzinflate|str_rot13)\s*\("
# stealthy writing
rg -n "file_put_contents\s*\(|fopen\s*\("
# dynamic include
rg -n "include|require" -g '!vendor/**'
Hardening the environment (Nginx/Apache)
Block PHP in /uploads/
Nginx:
1
location ~* ^/wp-content/uploads/.*\.php$ { return 403; }
Apache (.htaccess):
1
2
3
4
5
6
<Directory "/var/www/html/wp-content/uploads/">
php_admin_flag engine off
<FilesMatch "\.php$">
Require all denied
</FilesMatch>
</Directory>
Other measures • disable allow_url_include and, if possible, restrict with open_basedir • keep display_errors=Off in production and centralized logs • use WAF (rules for known webshells/obfuscators)
Hunting and telemetry (ideas)
• modified file + HTTP request with suspicious parameter → PHP process running system/exec • spikes in base64_decode/gzinflate in trace sampling • behavioral signature: decodes data + executes string • IOC table (paths/domains) propagated to SIEM
Reducing false positives
• legitimate deobfuscators exist, so require two signals: decoding + execution • ignore paths of known libraries (vendor, libs) when appropriate • observe short windows: stubs usually “light up” right after upload
Closing
At the end of the day, much of the “magic” in these backdoors is just cheap obfuscation to avoid drawing attention in a diff. Decoding safely, understanding the sequence and cutting off the vectors is usually enough to dismantle the operation.
