그저 홈 서버 정기 점검을 하려던 아침, 첫 지표부터 이미 이상했다. . 16 스레드 머신이 평소엔 놀고 있는데 load가 10.65에 붙어 있었다. 즉 CPU의 3분의 2를 뭔가가 계속 먹고 있었다는 뜻이다.

누군가 내 모르게 내 머신을 쓰고 있었다. 이 글은 그것을 찾아내고, 죽이고, 들어온 문을 막기까지의 전 과정이다. 그리고 되도록 직접 따라 돌려볼 수 있는 체크리스트로 썼다. 내 머신에 이렇게 몰래 도는 수상한 프로세스가 없는지 확인하는 방법으로.

1단계: 누가 내 CPU를 먹고 있나#

load가 높으면 먼저 누가 차지하고 있는지 본다. uptime부터 범인을 지목하기까지, 그날의 터미널 기록은 이랬다(숫자는 전부 실제로 출력된 것):

console
$ uptime
 03:14  up 12 days,  load average: 10.65, 9.90, 7.40

$ top -bn1 -o %CPU | head
    PID USER     %CPU  %MEM  COMMAND
2714011 alice   996.0   3.9  XXAkjjBB
 525303 alice   429.0   0.0  XXigEEFC

$ docker stats --no-stream
NAME             CPU %      MEM USAGE
nas-frontend     1017.32%   2.41GiB

세 숫자 중 하나도 정상이 아니다. 16 스레드 머신이 놀고 있는데 load는 10.65. XXAkjjBB라는, 대소문자가 뒤섞인 파일명의 무언가가 996% CPU(10코어 점유)와 2.4GB 메모리를 먹고 있다. 게다가 Next.js 프론트엔드 컨테이너 nas-frontend까지 1017% CPU를 나타내고 있다.

XXigEEFC 쪽은 인데 CPU 칸은 429다. 이미 죽은 프로세스에 CPU 숫자가 있는 건 이상하고, 이는 집계의 잔여물이다.

멀쩡한 서비스는 이런 이름을 붙이지 않는다. 대소문자가 뒤섞인 무의미한 파일명은 마이너의 가장 전형적인 모습이며, 프로세스 목록에서 한눈에 알아보지 못하도록 만들어진 것이다.

2단계: /proc 3종 세트로 정체를 밝힌다#

수상한 PID를 봐도 바로 죽이기 전에 세 가지를 먼저 묻는다. 너는 무엇인가, 어떻게 시작됐나, 어디서 왔나. Linux의 /proc/<pid>/는 이 세 답을 전부 그 자리에 펼쳐 준다. 추가 도구는 아무것도 필요 없다. 그때 내가 친 세 줄과, 돌아온 것:

console
$ ls -la /proc/2714011/exe
lrwxrwxrwx 1 alice alice 0 ... /proc/2714011/exe -> '/tmp/XXAkjjBB (deleted)'

$ cat /proc/2714011/cmdline | tr '\0' ' '
/tmp/XXAkjjBB

$ cat /proc/2714011/environ | tr '\0' '\n'
NODE_VERSION=20.11.0
HOSTNAME=0.0.0.0
PORT=3001
...

세 줄이 각각 퍼즐 한 조각을 내민다:

  • exe가 가리키는 곳은 . 실행 파일이 /tmp에 있고, 게다가 이미 디스크에서 삭제됐는데 프로세스는 계속 돌고 있다. 멀쩡한 서비스는 이러지 않는다. '착지 후 즉시 자가 삭제'라는 교과서적 동작이다.
  • environNODE_VERSION, HOSTNAME=0.0.0.0, PORT=3001은 내 로그인 셸이 가질 법한 변수가 아니라, 어떤 Node.js Docker 컨테이너 내부의 환경처럼 보인다. 이 마이너는 내 컨테이너 중 하나에서 생겨난 것이다.

3단계: 공격 체인을 끌어낸다#

부모 프로세스를 따라 올라가면(ps -o ppid= -p <pid>, 또는 htop의 트리 뷰) 전체 그림이 분명해진다:

부모 프로세스는 next-server, 내 Next.js 프론트엔드 컨테이너 중 하나다. 침해된 뒤 차례로 sh(셸 획득), base64(끼워 넣은 payload 디코드)를 spawn했고, 마지막에 XXAkjjBB를 띄워 채굴을 시작했다. 그래서 그 컨테이너가 docker stats에서 1017% CPU를 나타낸 것이다. CPU를 먹고 있던 건 실은 그것이 fork한 마이너였다.

4단계: 제거#

다행히 이 마이너는 내 일반 사용자 권한(root 아님)으로 돌고 있었다. 내 권한으로 시작한 컨테이너에서 생겨나 내 권한을 물려받았기 때문이다. 그래서 죽이는 데 sudo가 필요 없다:

bash
# 마이너를 죽인다(자기 권한으로 도는 프로세스는 자기 권한으로 죽일 수 있다)
kill -9 2714011

# 좀비는 못 죽인다, 이미 죽었으니까. 회수하려면 부모 프로세스를 죽인다
docker stop <침해된 컨테이너>

죽이면서 load average가 내려가는 걸 지켜본다: 10.65 → 8.80 → 6.30, CPU가 식어 간다. 좀비는 부모 컨테이너를 멈춘 뒤 init이 회수해야 비로소 진짜로 사라진다. 좀비를 kill해도 소용없다, 이미 죽었으니. 처리해야 할 건 아직 살아 있는 부모 프로세스 쪽이다.

5단계: 백도어를 남기지 않았는지 확인한다#

지금 도는 프로세스를 죽이는 건 응급처치일 뿐이다. 정말 골치 아픈 건 **지속성(persistence)**이다. 공격자가 어떤 시작 스크립트나 스케줄러에 '다시 내려받아 실행'을 심어 뒀다면, 죽인 1분 뒤에 다시 살아난다. 마이너가 가장 즐겨 숨는 세 곳을 하나씩 확인한다:

bash
# 1. 스케줄러: cron이 가장 흔한 재생 경로
crontab -l
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.*/

# 2. 셸 시작 파일: 로그인 시 자동 실행
cat ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null | grep -iE 'curl|wget|base64|/tmp|http'

# 3. /tmp에 다른 실행 가능 파일이 남아 있는지
find /tmp -type f -executable -ls 2>/dev/null

WARNING

이 중 어딘가에서 수상한 것이 발견되면, 문제는 단일 프로세스보다 훨씬 심각하다 cron에 매분 curl … | bash를 도는 항목이 있다면, 공격자는 이미 당신의 스케줄러에 쓰기 권한을 얻은 것이다. 프로세스를 죽이는 것만으로는 턱없이 부족하다. 이번엔 세 곳 모두 깨끗했기에(/tmp 비어 있음, cron 무사, 셸 시작 파일 정상) 아직 뿌리내리지 않았다고 확신할 수 있었고 응급처치로 충분했다. 하지만 이 단계는 건너뛸 수 없다. 지속성을 확인하지 않고 '제거 완료'를 선언하는 것은 사고 대응에서 가장 흔한 실수다.

근본 원인: 닫지 않은 문과, 만료된 자물쇠#

지혈 뒤의 진짜 문제는 그것이 어떻게 들어왔는가다. 두 요인이 겹쳐 있었고, 한쪽만으로는 성립하지 않는다.

첫째, 그 프론트엔드 컨테이너의 포트가 0.0.0.0에 그대로 바인딩돼 있었다. 원래는 nginx 리버스 프록시를 통해서만 접근돼야 하는데, docker-compose에는 127.0.0.1:13001:3001이 아니라 13001:3001이라고 적혀 있었다. 이 차이가 치명적이다:

. 전자는 누구나 '내 공인 IP:13001'에 직접 때려 넣을 수 있고, nginx와 Cloudflare를 완전히 우회한다. nginx의 access log를 뒤져도 이 컨테이너의 기록은 하나도 없었는데, 공격 트래픽이 nginx를 거치지 않고 노출된 컨테이너 포트를 직접 두드렸기 때문이다.

둘째, 그 컨테이너가 돌리던 Next.js는 16.0.6, 알려진 구멍을 가진 버전이었다. 문이 열려 있고(포트 노출), 자물쇠가 고장 난(낡은 프레임워크) 상태. 공격자는 공개된 익스플로잇 하나로 셸을 잡았고, 나머지는 마이너를 내려받는 정형 작업이었다.

이 RCE에는 이름이 있다. CVE-2025-66478(상류의 React Server Components에서는 CVE-2025-55182, 별명 React2Shell), CVSS는 만점인 10.0. Next.js 16.0.0부터 16.0.6까지 모두 영향을 받고, 공격자는 특수 제작한 Next-Action 헤더를 붙인 요청 하나만 보내면 인증 정보 없이 임의 코드를 실행할 수 있다. 수정은 16.0.7에 들어갔다. 그리고 이게 공개된 건 2025년 12월, 내가 침해를 알아챈 건 2026년 4월. 그 사이 몇 달이 바로 내가 업그레이드를 미룬 공백기였다. 문은 열려 있고 자물쇠는 고장 난 채로 방치돼 있었다.

강화: 머신의 모든 문을 점검한다#

이 구멍을 막은 뒤 가장 먼저 한 일은 '이 문이 열려 있었다면, 다른 문도 열려 있지 않을까' 의심하는 것이었다. 머신 전체의 포트 바인딩을 스캔했다:

bash
# 0.0.0.0에서 대기 중인(노출된) 포트를 전부 나열
ss -tlnp | grep '0.0.0.0'
# Docker라면 각 컨테이너의 매핑도 본다
docker ps --format '{{.Names}}\t{{.Ports}}'

결과, 원래는 리버스 프록시를 통해서만 접근돼야 하는데 0.0.0.0에 벌거벗은 채 노출된 서비스가 줄줄이 나왔다. 가장 등골이 서늘했던 건, PostgreSQL 데이터베이스가 인터넷에 직접 열려 있었다는 것. 채굴보다 훨씬 심각하고, 데이터베이스 통째로 언제 털려도 이상하지 않다. 전부 127.0.0.1로 되돌리고, nginx만 들어올 수 있게 했다.

마무리로 세 가지:

리버스 프록시 전용 서비스를 전부 127.0.0.1로 되돌린다

'nginx를 통해서만 접근돼야 하는' 컨테이너에는 포트에 127.0.0.1: 접두어를 붙인다. 공개 입구는 nginx 하나로만 좁히고, 다른 서비스는 인터넷에 대해 보이지 않게 한다. 이번 사고에서 가장 저비용·고효율의 한 수다.

낡은 프레임워크를 업그레이드한다

RCE를 가진 Next.js를 16.0.6에서 당시 최신 안정판으로 올린다. 문을 닫고(127.0.0.1에 바인딩), 자물쇠도 새로 바꾼다(알려진 구멍을 막는다). 둘 다 해야 다음 취약점에서 같은 문으로 들어오지 않는다.

각 컨테이너에 리소스 상한을 건다

모든 컨테이너에 mem_limitcpus를 붙인다. 이는 침입을 막지는 못하지만 폭발 반경을 제한한다. 만에 하나 또 다른 컨테이너에 마이너가 심어져도, 먹을 수 있는 건 기껏해야 자기 할당량뿐이라, 이번처럼 한 프로세스가 10코어를 점유해 머신 전체를 끌어내리는 일은 없다.

지금 바로 돌려볼 수 있는 체크리스트#

당신도 인터넷에 노출된 머신(VPS, 홈 서버, 아니면 포트 포워딩만 열어 둔 가정용 PC라도)을 관리한다면, 5분만 들여 이걸 한 번 돌려 보길:

수상한 프로세스 자가 점검(한 줄씩 실행)
  1. load와 CPU 보기: uptime으로 load average, top을 CPU 순으로. 노는 머신인데 load가 높거나, 낯선 프로세스가 CPU를 점유하고 있으면 적신호.
  2. 수상한 파일명: ps aux --sort=-%cpu | head — 대소문자가 뒤섞인 무의미한 COMMAND 이름은 매우 의심스럽다.
  3. 실행 파일 밝히기: ls -la /proc/<pid>/exe. /tmp, /dev/shm을 가리키거나 (deleted) 표시가 붙어 있으면 거의 확실히 악성.
  4. 어떻게 시작됐고 어디서 왔는지 보기: cat /proc/<pid>/cmdline | tr '\0' ' 'cat /proc/<pid>/environ | tr '\0' '\n' — 환경 변수가 어느 컨테이너/서비스에서 생겨났는지 알려 준다.
  5. 지속성 확인: crontab -l, cat /etc/crontab, ls /etc/cron.d/, 그리고 ~/.bashrccurl … | bash 같은 게 없는지.
  6. 바깥으로 난 문 확인: ss -tlnp | grep 0.0.0.0으로 인터넷에 대기 중인 것을 전부 나열하고, 하나씩 '이건 정말 공개가 필요한가' 자문한다. 데이터베이스, 관리 화면, 내부 API는 거의 필요 없다.

이번 청구서: 10코어를 점유하는 마이너, 인터넷에 벌거벗은 채 노출된 데이터베이스, RCE를 가질 만큼 낡은 프레임워크. 그 전부가 같은 한 번의 점검에서 파헤쳐졌다. 고치는 건 어렵지 않았다. 어려운 건 보러 갈 줄 아는 것이다.

參考連結
  • Next.js CVE-2025-66478 —— 이번에 뚫린 RCE의 공식 권고문nextjs.org
  • Linux /proc 파일시스템 —— 프로세스 포렌식의 일차 정보man proc
  • Docker —— localhost에만 바인딩하는 포트 매핑 문법Docker Docs
  • OWASP —— 서버 강화와 최소 노출 원칙OWASP