Working out what you have
You found eval and a wall of base64 in a PHP file
Somebody wrote that block so you could not read it. Turning it back into readable PHP takes two commands and no knowledge of the language, and the same two commands tell you whether you are looking at an injection or at a packed library minding its own business.
What the line is doing
Every block of this kind has two halves. One half turns a string back into PHP source. The other half runs it. Read them separately and the whole family stops being mysterious.
The half that runs it is almost always
eval. It takes a string and executes it as PHP.
eval is a language construct rather than a function, so it
does not appear in disable_functions and a host cannot
switch it off that way. Attackers also reach for
call_user_func, call_user_func_array, a
backtick, or a variable holding a function name, as in
$f = 'sys' . 'tem'; $f($cmd);. Functions that take a
callback are a quieter route to the same place:
array_map, array_filter,
array_walk, usort,
preg_replace_callback, ob_start and
register_shutdown_function all accept a function name as a
string, and all appear in the NSA's published web shell rules for that
reason.
The half that decodes it is a chain of ordinary string functions, applied in reverse order to how they were applied when the payload was packed.
-
base64_decodeturns printable ASCII back into bytes. It is the one you will see most. -
gzinflate,gzuncompressandgzdecodedecompress. They are not interchangeable:gzinflateexpects raw deflate,gzuncompressexpects the zlib framing thatgzcompressproduces, andgzdecodeexpects gzip. Use the one the file names. -
str_rot13andstrrevshuffle characters. Both are their own inverse, so applying either one twice gives you back what you started with. -
urldecode,hex2bin,packandchrspell text out of numbers. A run ofchr(101).chr(118).chr(97).chr(108)is the wordevalwritten the long way round. - Escape sequences inside a double-quoted string. PHP expands
\x65and\145to the letterewhile it is parsing, so a name spelled that way never appears as text anywhere in the file.
Stack the two halves and you get the shape the whole cluster is named
after: eval(gzinflate(base64_decode('...'))). The NSA's
rule for it matches eval followed by any run of
base64_decode, str_rot13,
gzinflate, gzuncompress, strrev
or gzdecode, which is the vocabulary in one line.
Three constructs that no longer work
Older guides name three more execution routes. PHP removed all three, the last two in version 8.0, and you should still recognise them, because finding one dates the compromise.
preg_replace with the /e
modifier. It substituted the backreferences into the
replacement string and then evaluated the result as PHP. PHP 5.5
deprecated it and PHP 7.0 removed it: the upgrade notes read "Removed
support for /e (PREG_REPLACE_EVAL) modifier. Use preg_replace_callback()
instead." On PHP 8.2 the call emits Warning: preg_replace(): The
/e modifier is no longer supported, use preg_replace_callback
instead and returns nothing. Rule sets still search for the
literal string /(.*)/e because the files are still out
there.
create_function. It built a function from
two strings by calling eval internally. Deprecated in PHP
7.2, removed in PHP 8.0. On PHP 8.2 a file that calls it stops the
request with Fatal error: Uncaught Error: Call to undefined
function create_function(). An injection that sat quiet for
years surfaces as a white screen the day your host moves you to PHP 8.
assert with a string argument. Older PHP
evaluated a string passed to assert as code. String
assertions were deprecated in 7.2, and from PHP 8.0 the manual is blunt:
"assert() will no longer evaluate string arguments". Checked on PHP
8.2.31 with assertions switched on,
assert('print("X");') printed nothing and the script
carried on. assert is still worth grepping for, because
assert around a real expression does still evaluate that
expression when zend.assertions is set to 1.
A dead construct is not a clean file. It means that one route stopped working, and the rest of the file, and the rest of the site, still needs reading.
Decode it without running it
Copy the file to your own machine first and work on the copy. Nothing below needs to happen on the server, and none of it executes the payload.
One thing to rule out before anything else. Plenty of published
walkthroughs tell you to change eval to echo
and load the file in a browser. Do not do that on a site that is live.
Every other line in the file still runs, and injected blocks routinely
do their work before the eval line is reached. Never put
eval inside a php -r command either, which
reaches the same place with fewer steps in between.
Pull the string out
A base64 blob is a long run of letters, digits, +,
/ and =. Grep for runs of forty characters or
more and you will catch it without reading the file by eye.
grep -oE "[A-Za-z0-9+/=]{40,}" suspect.php > blob.b64
Check what landed in the file before you decode it. A minified script or
a data URI will also match, and you want the one string the
eval line refers to.
One layer of base64, and nothing else
base64 --decode blob.b64 > layer1.txt
If the blob picked up a stray character, GNU base64 stops
with base64: invalid input and exits 1. Add
-i to skip the junk instead:
base64 -di blob.b64. Open layer1.txt in a text
editor. Do not name it with a .php extension inside a web
root.
Compressed layers, using PHP as a decoder only
Coreutils has no tool for raw deflate, so this is the one place PHP is the practical answer. Call the decoders and nothing else. The payload never executes, because nothing in this command runs it.
php -r 'echo gzinflate(base64_decode(file_get_contents("blob.b64")));' > layer1.php
Read the function names in the original line and apply them in the same
order. Guessing the wrong decompressor fails loudly and safely: calling
gzuncompress on raw deflate data prints
PHP Warning: gzuncompress(): data error and returns false.
Then look at what came out. If it is another wall of base64, repeat. Sucuri published a sample in October 2019 that took 91 rounds of this before the real code appeared. Each round is the same two commands.
When the function names themselves are hidden
A file with no readable base64_decode in it can still be
calling one. These are the spellings that turn up most often, taken from
the NSA's published rules and decoded on PHP 8.2.31 to confirm what each
one produces.
| What sits in the file | What it spells |
|---|---|
"\142\141\163\145\66\64\137\144\145\143\157\144\145" | base64_decode, in octal escapes |
"\x70\x72\x65\x67\x5f\x72\x65\x70\x6c\x61\x63\x65" | preg_replace, in hex escapes |
hex2bin("6576616C28") | eval( |
strrev("edoced_46esab") | base64_decode |
strrev("etalfnizg") | gzinflate |
"base" . (32 * 2) . "_de" . "code" | base64_decode, assembled by arithmetic |
ZXZhbCg inside a longer blob | eval(, one layer down |
YmFzZTY0X2RlY29kZ inside a longer blob | base64_decod, one layer down |
${$name} | a variable variable, used to reach GLOBALS |
Decoding these by hand is one command each. Escape sequences only expand
inside double quotes, so php -r 'echo "\x65\x76\x61\x6c";'
prints the word and runs nothing, because a string literal is all it is.
Reading what came out
You now have plain PHP. You do not have to follow the logic. Look at what the code reaches for, because that settles the question faster than understanding it does.
- Input taken straight from the request.
$_GET,$_POST,$_COOKIE,$_REQUEST,getallheaders()orphp://inputfeeding anything that executes. That is a back door, and it is a back door whoever wrote it. - A remote address.
file_get_contentsorcurl_execagainst a domain you do not recognise, with the response executed, written to disk, or printed into your pages. - Writing PHP.
fwrite,file_put_contentsorcopyproducing a.phpfile, especially underwp-content/uploads. - Touching accounts.
wp_create_user,wp_insert_user,add_roleor a directINSERTinto the users table. - Deciding who sees what. Reading the user agent or the referrer and behaving differently for a search engine crawler than for you.
- A licence check. One documented endpoint on the domain of the vendor whose plugin you are reading, sending a key and reading a yes or a no. This one is honest, and it is the most common reason a paid plugin carries an encoded block.
Packed code that is not malware
Some legitimate PHP is unreadable, and telling the two apart is the skill worth having. Density tells you nothing on its own. The question that separates them is whether the file is uniform.
An injection is a guest in someone else's file. It sits above the opening docblock, or below the last closing brace, and the code around it is formatted by a person while the block is not. Delete it and the file still reads as a file with a purpose.
A packed library is the whole file. There is no host it was added to, because a build step produced the entire thing in one piece. It usually carries a licence header, and the identical file appears in the vendor's published archive.
A worked example from the project's test install. Sorting every PHP file
outside vendor/ by its longest line put a deliberately
planted test shell first, at 5,658 characters on one line. Second, at
3,014, was
wp-content/plugins/pexlechris-adminer/inc/adminer.php. That
second file is Adminer 5.4.2, a single-file database tool published at
adminer.org: 1,772 lines, an Apache and GPL header at the top, two calls
to base64_decode inside it. Both files are dense. Adminer
declares who wrote it and under which licence, and it arrived as one
piece from a project you can look up.
Four checks separate them, in the order that costs least.
- Where does the block sit? Byte zero of the file, or after the
final
?>, means somebody appended it to a file they did not write. In the middle of a class, indented like its neighbours, usually means it belongs there. - Is there a header? A licence, a version, a project URL, a source map comment. Build tools emit these. Injections do not.
- Does the same file exist upstream? Download the exact version of the plugin or theme and compare byte for byte. Identical settles it, and the full procedure for each file class is in restore modified core, plugin and theme files.
- What did it decode to? Everything in the previous section. A licence checker talks to its own vendor. A back door talks to the request.
One useful asymmetry: a plugin or theme hosted on WordPress.org should not carry an obfuscated block at all. Guideline 4 of the directory rules says obscuring code "is not permitted", so a directory-hosted component that decodes a blob and runs it is either a plugin about to be pulled or a file that was modified after you installed it. Premium plugins sold elsewhere are not bound by that rule and often do exactly this to protect a licence check.
If the checks leave you unsure, isolate the file instead of deciding about it. The three checks that tell a real detection from a false flag covers what to do with a file you cannot resolve.
Finding the rest of them
One block is rarely the whole job. Sweep the site before you edit anything, so you know the size of what you are dealing with.
grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/
Expect noise, and expect most of it in one place. On a ten-plugin test
install with 3,173 PHP files under wp-content, that command
matched 28 files. Twenty-five of them sat inside the
vendor/ directory of one development tool, which ships a
PHP parser and a code sniffer that name these functions because their
job is to detect them. Filter that out first and read what is left:
grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/ \
| grep -v "/vendor/"
The grep misses everything spelled in hex, octal or reversed characters.
Length catches those, because packing a payload into source produces one
absurdly long line. Sort every PHP file by its longest line and read
from the top. Ignore the total rows wc adds.
find wp-content -name "*.php" -not -path "*/vendor/*" -exec wc -L {} + \
| sort -rn | head -20 Then check the places these blocks are put, which are the same handful on nearly every site.
- The first line of
wp-config.php. It loads on every request, WordPress never overwrites it, and a core reinstall cannot touch it because no official archive contains one. -
wp-content/themes/<theme>/functions.php,header.phpandfooter.php. Same reasoning, plus a theme update usually leaves an active child theme alone. - Every theme you have, including the ones you never activated. A dormant theme is still on disk and still writable. That copying habit is the whole mechanic of wp-vcd, the nulled theme backdoor, which rebuilds itself from whichever copy you left behind.
-
wp-content/mu-plugins/. Files there load automatically, before normal plugins, and never appear in the plugins list you look at. -
wp-content/uploads/. Nothing there should execute, which is why it is the favourite drop point for a second copy. PHP files in the uploads folder covers finding them and stopping the directory running PHP at all. - Added files under
wp-admin/andwp-includes/. A core file verification reports these as a warning and still exits successfully, so an extra file inwp-includes/passes the check that most people trust. - Inside an image. The NSA rules carry a signature for a file
that starts with GIF, PNG or JPEG magic bytes and contains a
<?phptag further down. It opens as an image and executes as PHP the moment something includes it. - An
.htaccessthat loads a decoder for you. Aphp_value auto_prepend_fileline prepends a file to every PHP request on the site, so the payload needs noincludeanywhere in your code. Rule sets flag that directive for this reason.
Extensions do not have to say .php. A file called
wp-content/uploads/2026/01/logo.ico executes fine when
another file includes it, and your grep with
--include="*.php" never saw it.
Cutting the block out by hand
The goal is a file that is byte-identical to the original except for the statement you removed. Work in a plain text editor, not a word processor, and keep the file's line endings as they are.
This is the shape, with a blob that decodes to a harmless
echo so the example is safe to read. The first line is the
guest. Everything under it is the file that was there before.
<?php $s="ZWNobyAiTm90aGluZyBoZXJlIGJ1dCB0aGlzIHNlbnRlbmNlLiI7"; eval(base64_decode($s)); ?>
<?php
/**
* Theme functions.
*/
function example_setup() {
add_theme_support( 'title-tag' );
}
The cut removes the whole first line, both of its tags included, and
leaves the second <?php at byte zero with nothing in
front of it.
<?php
/**
* Theme functions.
*/
function example_setup() {
add_theme_support( 'title-tag' );
} - Copy the file off the server first. Keep the infected copy somewhere outside the web root. It is the only record of what was done to you, and you will want it if the removal goes wrong.
- Find both ends of the statement. Injections are almost always
one PHP statement, so it starts after a
;, a}or the opening tag, and ends at the next;outside quotes. Read to the end of the long line rather than stopping at the first semicolon you see inside the encoded string. - Check whether the block brought its own tags. A block at the
very top often arrives as its own
<?php ... ?>pair before the file's real opening tag. Delete the tags with it, and delete any blank line or space left in front of the file's original<?php. Bytes before the opening tag get sent to the browser and produce a "headers already sent" error on every page. - Delete that statement and nothing else. No reformatting, no tidying the indentation of the surrounding code, no adding a trailing newline the file did not have.
- Search the same file again. A second copy of the same block
further down is common, and eyes skip it. Run
grep -c base64_decodeon the file and confirm the count is what you expect. - Check the file still parses.
php -l suspect.phpreads the file and reports syntax errors without executing anything. It printedNo syntax errors detectedon a file containingeval(base64_decode(...))and ran none of it. Without shell access, upload the file and load a page that uses it: a parse error is loud and immediate. - Compare against upstream if there is one. Download the same
version and diff. If the file is a custom theme or
wp-config.php, there is no upstream copy to compare against, and cleaning a file that has no clean copy anywhere is the method for that case. - Load the site. Front page, admin, and whatever feature the file belonged to.
A file that is nothing but the block is a different job. Confirm nothing includes it first, by searching the site for its filename and for any function or class it defines. A file nothing references can go. A file your theme includes on every request has to be cleaned rather than removed, or the site stops loading.
Do not run the file to check your work, and do not open a URL that points at it. Parsing it and reading it are enough.
Closing the way in
You removed the payload. The payload was never the problem. Something gave an attacker the ability to write to your filesystem, and until that is closed you are cleaning the same file next week.
Start with what runs on a timer or loads invisibly, because reinfection within minutes means a second copy is still executing somewhere.
wp cron event list --fields=hook,next_run_relative
wp plugin list --status=dropin --fields=name,title
find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort A hook name you do not recognise, a drop-in you did not install, or a cluster of PHP files written on the same minute as the block you just removed all point at the rest of the infection. If it comes straight back, the malware came back after the cleanup works through the persistence mechanisms in order.
Then the entry point itself. Four cover most cases.
- An outdated plugin or theme. Arbitrary file upload and arbitrary file write flaws are how blocks like this arrive. Update everything, and remove anything you are not using rather than leaving it deactivated on disk.
- A nulled theme or plugin. Paid software from a free download site frequently carries its own installer. Nothing was exploited, because you ran it yourself.
- A stolen password. Administrator, FTP, SFTP, hosting panel and
database. Rotate all of them, and check the administrator list
afterwards with
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered. - A writable uploads directory that executes PHP. Stopping that is a few lines of server configuration and it removes an entire class of drop point.
Re-scan after the credentials are rotated, not before. A scan run while the attacker still has your password measures how fast they can rewrite the file.
Questions
- Does base64_decode in a file mean the site is hacked?
- No. It is an ordinary PHP function and honest code uses it constantly. On a small test install with ten plugins, a grep for eval, gzinflate, str_rot13 and assert matched 28 files out of 3,173, and 25 of those sat inside the vendor directory of one developer tool. The function is a reason to read the file, not a verdict on it.
- Can I change eval to echo and load the page to see the payload?
- Do not do that on a live site. It leaves every other line in the file running, and injected blocks often do their work before the eval line is reached. Copy the file to your own machine and decode the string on the command line instead. That gives you the same text and executes nothing.
- How many layers of encoding can there be?
- As many as the attacker had patience for. Sucuri published a sample in October 2019 with 91 nested layers, each one revealing another encoded string underneath. Peeling them is repetitive rather than difficult. Keep applying the decoder named in the file until the output stops being an encoded string.
- The file uses create_function or preg_replace with the /e modifier. Is it still a threat?
- Not as a way to run code. PHP removed the /e modifier in 7.0 and create_function in 8.0. On PHP 8.2 the first prints a warning and returns nothing, and the second stops the request with a fatal error about an undefined function. What the file still tells you is that somebody had write access to your server, probably years ago, and you should assume they had it everywhere.
- Should I delete the whole file or just the block?
- Depends on what the file is. A theme or plugin file with an injected block at the top is a real file your site needs, so cut the block and leave the rest untouched. A file that is nothing but the block belongs to nobody, and once you have confirmed no other file includes it, it can go.
- Can I search and replace every eval on the site at once?
- No. A blanket replace across wp-content will hit legitimate library code and break plugins that were working this morning, and it will miss every block that spells the function name in hex, octal or reversed characters. Read each hit and decide on it.