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.

Checked against a live WordPress install on .

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_decode turns printable ASCII back into bytes. It is the one you will see most.
  • gzinflate, gzuncompress and gzdecode decompress. They are not interchangeable: gzinflate expects raw deflate, gzuncompress expects the zlib framing that gzcompress produces, and gzdecode expects gzip. Use the one the file names.
  • str_rot13 and strrev shuffle characters. Both are their own inverse, so applying either one twice gives you back what you started with.
  • urldecode, hex2bin, pack and chr spell text out of numbers. A run of chr(101).chr(118).chr(97).chr(108) is the word eval written the long way round.
  • Escape sequences inside a double-quoted string. PHP expands \x65 and \145 to the letter e while 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.

bash
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

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

bash
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() or php://input feeding anything that executes. That is a back door, and it is a back door whoever wrote it.
  • A remote address. file_get_contents or curl_exec against a domain you do not recognise, with the response executed, written to disk, or printed into your pages.
  • Writing PHP. fwrite, file_put_contents or copy producing a .php file, especially under wp-content/uploads.
  • Touching accounts. wp_create_user, wp_insert_user, add_role or a direct INSERT into 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.

  1. 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.
  2. Is there a header? A licence, a version, a project URL, a source map comment. Build tools emit these. Injections do not.
  3. 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.
  4. 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.

bash
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:

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

bash
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.php and footer.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/ and wp-includes/. A core file verification reports these as a warning and still exits successfully, so an extra file in wp-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 <?php tag further down. It opens as an image and executes as PHP the moment something includes it.
  • An .htaccess that loads a decoder for you. A php_value auto_prepend_file line prepends a file to every PHP request on the site, so the payload needs no include anywhere 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.

functions.php, before php
<?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.

functions.php, after php
<?php
/**
 * Theme functions.
 */
function example_setup() {
    add_theme_support( 'title-tag' );
}
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Search the same file again. A second copy of the same block further down is common, and eyes skip it. Run grep -c base64_decode on the file and confirm the count is what you expect.
  6. Check the file still parses. php -l suspect.php reads the file and reports syntax errors without executing anything. It printed No syntax errors detected on a file containing eval(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.
  7. 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.
  8. 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.

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

Next

Doing this without the command line

Everything above is one file at a time: extract the string, decode it, judge it, cut the statement, check the file still parses. It works, and it is the same five moves on every file you find. Segurium does the same thing across the whole site at once, and the part that matters is the last step. It removes the injected statement and writes the rest of the file back unchanged, so a theme file with a block at the top comes out as a working theme file rather than a gap where your customisation used to be.

Where a hand pass usually loses is coverage. The grep on this page matches four names spelled in plain characters, so it walks past everything written in hex, octal or reversed, and it reads only files ending in .php, so it walks past the include hiding behind an image extension. Every file gets fingerprinted and checked instead, whatever it is called, and about 94% of them are settled by that fingerprint alone without anything reading their contents.