워드프레스 보안
mtime 대신 wordpress.org 체크섬으로 워드프레스 코어 변조를 잡는 점검기
파일 수정 시각 기준 점검이 정상 업데이트에는 경고하고 4년 된 웹셸은 놓친 이유와, wordpress.org 체크섬 API로 코어 변조와 낯선 파일을 찾는 PHP 코드, 그 한계를 정리했습니다.
NAS에 있는 워드프레스 사이트들을 매시간 점검하는 PHP 스크립트를 돌리고 있습니다. 처음 버전은 "지난 24시간 안에 wp-admin, wp-includes의 PHP 파일이 바뀌었나"를 파일 수정 시각(mtime)으로 봤습니다. 해킹당하면 코어 파일이 바뀔 테니까요.
그런데 어느 날 대시보드에 "코어 PHP 24시간 내 변경 200개"가 사이트 3곳에 빨갛게 떴습니다. 확인해 보니 그날 새벽 2시 11분~16분에 워드프레스가 스스로 코어 자동 업데이트를 한 거였습니다. 정상 업데이트인데 해킹처럼 보인 겁니다.
반대 문제도 있었습니다. 같은 날 전수 점검에서 리뷰 블로그의 wp-includes 안에 웹셸 5개가 2022년부터 들어 있는 걸 찾았는데, 이 점검기는 그 사이트를 계속 "정상"으로 표시하고 있었습니다. 4년 된 파일은 "최근 24시간 변경"에 안 걸리니까요.
mtime은 정상 업데이트에는 울리고 오래된 침입에는 조용했습니다. 그래서 기준을 wordpress.org 공식 체크섬으로 바꿨습니다.
wordpress.org 체크섬 API
wordpress.org는 버전별로 코어 파일 전체의 MD5 목록을 공개합니다.
curl -s "https://api.wordpress.org/core/checksums/1.0/?version=7.1.2&locale=en_US" | head -c 200
{"checksums":{"wp-trackback.php":"1384496d40da0ad11c7df922e4814a15","xmlrpc.php":"fb407463c202f1a8ab8783fa5b24ec13",...
이 목록 하나로 두 가지를 확인할 수 있습니다.
- 변조: 목록에 있는 파일의 MD5가 내 파일과 다르면 누군가 고친 겁니다.
- 낯선 파일:
wp-admin,wp-includes안에 목록에 없는 파일이 있으면 누군가 넣은 겁니다. 이 두 폴더는 워드프레스 코어만 들어가는 곳이라, 목록에 없는 파일은 있을 이유가 없습니다.
업데이트로 바뀐 파일은 새 버전 목록과 비교하면 일치하니 경고가 안 뜹니다. 오래 숨어 있던 파일은 목록에 없으니 날짜와 상관없이 걸립니다.
실제 코드
점검기에서 이 부분만 뽑았습니다(경로와 오류 처리는 줄였습니다).
function coreVerify(string $root, ?string $ver): array
{
$out = ['available' => false, 'mismatch' => [], 'extra' => []];
// 버전별 체크섬은 한 번 받아 캐시한다
$cache = __DIR__ . "/cks/core-{$ver}.json";
$cks = is_file($cache)
? json_decode(file_get_contents($cache), true)['checksums'] ?? null
: null;
if (!$cks) {
$body = file_get_contents(
"https://api.wordpress.org/core/checksums/1.0/?version={$ver}&locale=en_US");
$cks = json_decode($body, true)['checksums'] ?? null;
if ($cks) file_put_contents($cache, $body);
}
if (!$cks) return $out;
$out['available'] = true;
// 1) 목록에 있는 코어 파일이 원본과 다른가
foreach ($cks as $rel => $md5) {
if (str_starts_with($rel, 'wp-content/')) continue;
$p = "$root/$rel";
if (is_file($p) && md5_file($p) !== $md5) $out['mismatch'][] = $rel;
}
// 2) wp-admin, wp-includes 안에 목록에 없는 파일이 있는가
foreach (['wp-admin', 'wp-includes'] as $d) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator("$root/$d", FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
$rel = substr($f->getPathname(), strlen($root) + 1);
if (!isset($cks[$rel])) $out['extra'][] = $rel;
}
}
return $out;
}
버전은 각 사이트의 wp-includes/version.php에서 $wp_version을 정규식으로 읽습니다. 체크섬은 버전별로 파일에 캐시해서 매시간 wordpress.org를 부르지 않게 했습니다. 로케일은 en_US로 받았는데, 한국어판을 설치해도 PHP 코어 파일은 같고 번역은 wp-content/languages에 따로 들어가기 때문입니다. 이 폴더는 비교 대상에서 뺐습니다.
잘 잡는지 일부러 심어 봤습니다
바꾼 뒤 정말 잡는지 확인하려고 빈 파일을 하나 심었습니다.
touch wp-includes/zz_probe.php
php check.php # → "코어 폴더 낯선 파일 1개: zz_probe.php"
rm wp-includes/zz_probe.php
경고가 떴고, 지우니 사라졌습니다. 같은 방식으로 처음 돌렸을 때 걸린 것들입니다.
| 사이트 | 코어 폴더 낯선 파일 | 정체 |
|---|---|---|
| 리뷰 블로그 | 8개 | 웹셸 5개(oxqtyjed.php 등), 도어웨이 폴더, .suspected 잔해 |
| 개인 블로그 | 4개 | 2020년 감염 잔해 dqqwbngr.php 등 |
| 나머지 6곳 | 0개 | 변조 0, 낯선 파일 0 |
코어 파일 변조는 8곳 모두 0개였습니다. 공격자는 기존 파일을 고치기보다 새 파일을 끼워 넣는 쪽을 택했던 겁니다. mtime 방식으로는 둘 다 못 잡았습니다.
체크섬이 못 보는 곳
이 방법은 코어만 봅니다. 한계도 적어 둡니다.
wp-content는 목록에 없습니다. 플러그인과 테마,uploads는 따로 봐야 합니다. 점검기는uploads안의 PHP 파일,mu-plugins에 새로 생긴 파일, 새 관리자 계정을 별도로 확인합니다.- 플러그인도 체크섬이 있긴 합니다.
downloads.wordpress.org/plugin-checksums/슬러그/버전.json으로 받을 수 있어서 전수 점검 때는 이것도 대조했습니다. 다만 wordpress.org에 없는 개인 플러그인은 404가 나서 대조할 수 없습니다. wp-config.php와.htaccess는 사이트마다 다릅니다. 이 두 파일은 처음 상태의 MD5를 기준선으로 저장해 두고 바뀌면 경고합니다. 일부러 고쳤을 때만 기준선을 다시 만듭니다.- 체크섬을 못 받으면(인터넷이 끊겼을 때) 그때만 예전 mtime 방식으로 대신하고, 화면에 "체크섬 확인 불가"라고 같이 표시합니다.
참고로 테마 파일을 시그니처($_POST를 바로 쓰는 코드 등)로 훑었을 때는 정상 테마 파일 여러 개가 걸렸습니다. 패턴 검사는 오탐이 많아서, 원본과 비교할 수 있는 곳은 비교하는 게 훨씬 정확합니다.
9월 27일 현재 점검 대상 7곳 모두 7.1.2로 자동 업데이트됐고, 점검기는 버전이 올라간 것만 "주의"로 표시할 뿐 코어 경고는 없습니다.
확인한 공식 자료
구현 과정에서 판단 기준을 교차 확인한 공식 문서입니다. 글의 사례와 결론은 운영자가 직접 겪은 작업을 바탕으로 작성했습니다.