synology / self-hosting / security / performance

워드프레스 보안

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로 자동 업데이트됐고, 점검기는 버전이 올라간 것만 "주의"로 표시할 뿐 코어 경고는 없습니다.

확인한 공식 자료

구현 과정에서 판단 기준을 교차 확인한 공식 문서입니다. 글의 사례와 결론은 운영자가 직접 겪은 작업을 바탕으로 작성했습니다.