synology / self-hosting / security / performance

워드프레스 보안

재설치 없이 해킹된 워드프레스를 살린 순서: mu-plugins 로더부터

세 번 감염된 가족 블로그를 재설치하지 않고 격리, 코어 원본 교체, mu-plugins 백도어 제거, 가짜 관리자 삭제, 솔트 재생성 순서로 정리해 글 79개와 첨부 2,443개를 지킨 기록입니다.

가족 블로그로 쓰던 워드프레스가 해킹돼 있었습니다. 500 에러를 따라가다 발견했는데(그 과정은 따로 적었습니다), 파 보니 2020년, 2026년 2월, 2026년 7월 세 번에 걸친 침투 흔적이 있었습니다.

이런 경우 보통 권하는 건 "새로 설치하고 글만 옮겨라"입니다. 근데 이 블로그는 글 79개, 첨부 파일 2,443개에 가족 사진이 잔뜩 들어 있어서 재설치 대신 악성 코드만 걷어내는 쪽을 택했습니다. 이 글은 그 순서입니다. 순서가 중요해서, 제가 한 그대로 적습니다.

1. 손대기 전에 통째로 떠 둡니다

제일 먼저 웹 루트 전체와 DB를 백업했습니다. 악성 파일도 그대로 들어 있는 백업이지만, 나중에 "이 파일 원래 있던 거였나?"를 확인하려면 감염 상태 그대로의 사본이 필요합니다.

tar czf www_full.tgz -C /volume2/web/family-blog www      # 985MB
mysqldump --single-transaction family_db | gzip > family_db.sql.gz

경로와 DB 이름은 바꿔 적었습니다. 백업은 웹 루트 밖, 웹에서 접근할 수 없는 곳에 둡니다.

2. 격리 — 지우지 말고 옮깁니다

의심 파일은 삭제하지 않고 quarantine/ 폴더로 옮겼습니다. 이때 옮긴 것들입니다.

격리한 항목
위치내용
wp-content/uploads/파일 업로더, phpMiniAdmin, 무작위 이름 빈 PHP, class-wp-cache-9a8e2a14.php 웹셸
wp-content/plugins/가짜 플러그인 galex_13c3697f, security-headers-manager-a49be6, wordpress-cache-optimizer, wp-content-filter
wp-content/themes/가짜 테마 flavor_f697306b, 진짜 테마 안의 huqqkdmf.php, uaojkfle.php
wp-content/mu-plugins/galex_patch.php(658바이트), wp-core-update-52499f.php(307바이트)
그 밖ectoplasm/ectoplasm/, src/src/, files/files/, images/images/처럼 진짜 폴더 이름을 한 번 더 겹쳐 만든 드로퍼 폴더

격리한 파일은 4,321개였습니다. 대부분은 아래 3단계에서 통째로 교체한 옛 플러그인 폴더라, 악성 파일 자체가 이만큼이라는 뜻은 아닙니다.

3. mu-plugins를 먼저 봐야 하는 이유

wp-content/mu-plugins/는 "반드시 쓰는 플러그인(Must-Use)" 폴더입니다. 여기 넣은 PHP 파일은 관리자 화면의 플러그인 목록에서 끌 수도 없고, 워드프레스가 요청마다 자동으로 불러옵니다. 공격자한테는 가장 좋은 자리입니다.

여기서 나온 두 파일은 웹셸 자체가 아니라 다시 살아나게 하는 장치였습니다.

  • galex_patch.php — register_rest_route로 REST API에 /batch/v1 경로를 몰래 추가합니다. 워드프레스 코어에도 /batch/v1이라는 정상 경로가 있어서 로그에서 봐도 눈에 잘 안 띕니다.
  • wp-core-update-52499f.php — 파일을 복사해 쓰는 코드가 들어 있는 307바이트짜리 파일입니다. 웹셸을 지워도 다음 요청 때 다시 깔아 주는 역할입니다.

웹셸만 지우고 이 두 개를 남겨 두면, 다음 방문 한 번에 원상 복구됩니다. 그래서 mu-plugins는 제일 먼저, 파일 이름이 그럴듯해도 전부 열어 봐야 합니다. 이 블로그에서는 mu-plugins 안에 있던 두 파일이 전부 공격자 것이었습니다.

4. 코어는 고치지 말고 통째로 바꿉니다

감염된 코어 파일을 하나하나 비교해 고치는 건 놓치기 쉽습니다. wp-admin, wp-includes, 루트의 코어 PHP를 wordpress.org에서 받은 같은 버전(당시 6.9.1)으로 통째로 바꿨습니다. 받은 파일은 wordpress.org가 공개하는 SHA1과 대조한 뒤에 썼습니다.

curl -O https://wordpress.org/wordpress-6.9.1.tar.gz
curl -O https://wordpress.org/wordpress-6.9.1.tar.gz.sha1
echo "$(cat wordpress-6.9.1.tar.gz.sha1)  wordpress-6.9.1.tar.gz" | sha1sum -c -

wp-content와 wp-config.php는 코어가 아니라서 건드리지 않았습니다. 플러그인 8개(Rank Math, Site Kit, Autoptimize 등)도 같은 방식으로 wordpress.org 최신본으로 새로 받아 교체했습니다. 받을 수 있는 원본이 있으면 "고치기"보다 "갈아 끼우기"가 확실합니다.

5. DB 안의 흔적을 지웁니다

파일만큼 중요한 게 DB입니다. 확인하고 고친 순서입니다.

  1. 가짜 관리자 삭제. wpenginebot, wphiddenbot 두 계정이 관리자 권한으로 있었습니다. 호스팅 업체 봇처럼 보이게 지은 이름입니다.
  2. active_plugins 정리. 폴더를 지운 가짜 플러그인이 활성 목록에 남아 있으면 워드프레스가 계속 찾으려 합니다. wp_options의 이 값에서 빼 줬습니다.
  3. 모든 로그인 세션 끊기. 공격자가 가지고 있을지 모를 로그인 쿠키를 무효로 만들려고 전체 사용자의 세션을 끊었습니다. 워드프레스는 세션을 사용자 메타의 session_tokens에 저장합니다.
  4. 보안 키(솔트) 재생성. wp-config.php의 AUTH_KEY 등 8개 값을 새로 만들어 넣었습니다. wordpress.org 생성기를 쓰면 한 번에 받을 수 있습니다. 이것만 해도 기존 로그인 쿠키는 전부 무효가 됩니다.

그리고 공격자가 wp-config.php를 읽었다고 봐야 하니 DB 비밀번호도 바꿨습니다. 문제는 이 NAS의 워드프레스 여러 개가 DB 계정 하나를 같이 쓰고 있었다는 겁니다. 비밀번호 하나 바꾸는 데 wp-config.php 11개를 고쳐야 했습니다. 사이트마다 DB 계정을 따로 두는 게 맞습니다.

6. .htaccess를 새로 씁니다

마지막으로 500의 원인이던 .htaccess를 Apache 2.4 문법으로 다시 썼습니다. 워드프레스 기본 규칙에 두 가지를 더했습니다.

# xmlrpc.php 막기
<Files "xmlrpc.php">
    Require all denied
</Files>

그리고 wp-content/uploads/.htaccess에 PHP 실행을 막는 규칙을 넣었습니다. 이번에 나온 업로더와 웹셸이 uploads 안에 있었기 때문입니다.

<FilesMatch "\.(php|phtml|php[0-9]|phar|suspected)$">
    Require all denied
</FilesMatch>

결과와 남은 것

사이트는 200을 돌려주고 제목도 정상으로 나옵니다. 코어 파일은 wordpress.org 체크섬과 일치하고, 글 79개와 첨부 2,443개, 쓰던 테마는 손상 없이 그대로입니다.

하나 타협한 게 있습니다. 정리하면서 코어 파일 소유자를 웹 서버가 못 쓰게 바꿨더니, 이번엔 워드프레스 자동 업데이트가 멈췄습니다. 보안 업데이트를 못 받는 게 더 위험하다고 보고 권한을 되돌렸습니다. 그 얘기는 따로 정리했습니다. 대신 코어가 바뀌면 바로 알 수 있게 체크섬 점검기를 매시간 돌리고 있습니다.

확인한 공식 자료

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