크립토마이너 대응과 Coolify 보안 강화 — 오픈소스 기여까지
마이너를 제거하는 데 5분이면 충분했습니다
2편에서 CVE-2025-55182의 구조를 분석했습니다. Next.js 미들웨어의 인증 우회 취약점이 어떻게 크립토마이너 설치로 이어졌는지, 공격 경로를 하나씩 추적한 것이었습니다. 원인을 알았으니 이제 해야 할 일은 명확했습니다. 취약한 버전을 제거하고, 같은 일이 반복되지 않도록 방어를 쌓는 것입니다.
Coolify에서 saas-blog-front 서비스를 main HEAD로 재배포했습니다. Next.js 15.1.3에서 15.5.14로 업그레이드되면서, 취약점이 패치된 버전이 올라갔습니다. 재배포 후 docker top과 docker stats로 확인해보니, 컨테이너 안에는 dumb-init과 next-server만 남아 있었습니다. 마이너 프로세스 XXhAkAOM은 사라졌고, CPU 사용률 0%, 메모리 43MB. 정상이었습니다.
# 재배포 후 확인
docker top <container> aux
# → dumb-init + next-server만 남음. XXhAkAOM 제거됨.
# CPU 0%, 메모리 43MB
마이너 제거 자체는 5분이면 끝나는 일이었습니다. 하지만 "제거했으니 끝"이라고 생각할 수는 없었습니다. 같은 취약점이 아니더라도, 다음에 또 다른 경로로 뚫릴 수 있다는 걸 이번에 직접 경험했으니까요.
마이닝 풀 포트를 차단합니다
크립토마이너가 채굴을 하려면 마이닝 풀 서버에 접속해야 합니다. 컨테이너 내부에서 외부의 마이닝 풀로 나가는 연결을 차단하면, 설령 마이너가 다시 설치되더라도 실제 채굴은 불가능해집니다.
마이닝 풀이 일반적으로 사용하는 포트는 3333, 4444, 5555, 7777, 9999, 14444, 33333입니다. 이 포트들을 iptables로 차단했습니다.
# iptables 마이닝 풀 차단
sudo iptables -I DOCKER-USER -p tcp --dport 3333 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 4444 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 5555 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 7777 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 9999 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 14444 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 33333 -j DROP
# 규칙 영속화
sudo iptables-save | sudo tee /etc/iptables/rules.v4
여기서 주의할 점이 하나 있습니다. Docker는 UFW를 우회합니다. Docker 데몬이 자체적으로 iptables 규칙을 관리하기 때문에, UFW에서 아무리 포트를 차단해도 Docker 컨테이너의 아웃바운드 트래픽에는 적용되지 않습니다. 반드시 DOCKER-USER 체인에 규칙을 추가해야 합니다. 이 체인은 Docker가 패킷을 처리하기 전에 먼저 평가되는 유일한 사용자 정의 체인입니다.
iptables-save로 규칙을 파일에 저장해두지 않으면 서버 재부팅 시 규칙이 사라집니다. 영속화까지 마쳐야 비로소 완료입니다.
Docker capability를 제거합니다
다음은 컨테이너의 권한을 최소화하는 것이었습니다. Linux capability는 root 권한을 세분화한 것인데, 기본적으로 Docker 컨테이너에는 꽤 많은 capability가 부여됩니다. 웹 애플리케이션에 NET_RAW나 SYS_CHROOT 같은 권한이 필요할 리가 없습니다.
Coolify의 Custom Docker Options에 --cap-drop ALL을 추가했습니다. 모든 Linux capability를 제거하는 설정입니다. 배포해보니 정상적으로 동작했습니다. Next.js 서버를 띄우는 데 특별한 커널 권한은 필요하지 않으니까요.
Coolify가 무시하는 Docker 옵션들
--cap-drop ALL이 잘 되니, 추가 보안 옵션도 적용해보기로 했습니다.
먼저 --read-only와 --tmpfs /tmp:noexec를 시도했습니다. 컨테이너의 파일시스템을 읽기 전용으로 만들고, /tmp만 쓰기 가능하되 실행 권한은 제거하는 설정입니다. 마이너가 바이너리를 다운로드받아 실행하는 패턴을 원천 차단할 수 있습니다.
그런데 배포 후 확인해보니 이 옵션들이 적용되지 않았습니다. Coolify가 Custom Docker Options를 Docker Compose YAML로 변환하는 과정에서 --read-only와 --tmpfs를 아예 무시하고 있었습니다.
다음으로 --security-opt no-new-privileges:true를 시도했습니다. 컨테이너 내부에서 setuid를 통한 권한 상승을 막는 옵션입니다. 이번에는 배포 자체가 실패했습니다.
# Coolify가 생성한 docker-compose.yaml (문제)
security_opt:
- 'no' # ← 'no-new-privileges:true'가 'no'로 잘림
로그를 확인해보니 invalid security-opt: "no"라는 에러가 나왔습니다. no-new-privileges:true라는 값이 no로 잘려나간 것이었습니다.
Coolify 파서의 정규식 버그
왜 이런 일이 일어나는지 알기 위해 Coolify 소스코드를 확인했습니다. bootstrap/helpers/docker.php에 있는 convertDockerRunToCompose() 함수가 Custom Docker Options를 파싱하는 핵심 로직이었습니다.
이 함수 내부에는 $mapping 배열이 있습니다. --cap-drop, --security-opt 같은 Docker 옵션이 Compose YAML의 어느 키에 매핑되는지를 정의하는 테이블입니다. --read-only와 --tmpfs는 이 테이블에 아예 존재하지 않았습니다. 그래서 조용히 무시되었던 것입니다.
--security-opt는 매핑에 있었지만, 다른 문제가 있었습니다. 옵션 값을 추출하는 정규식이었습니다.
// Coolify 소스: bootstrap/helpers/docker.php
// 기존 (버그)
$regex = '/--(\w[\w-]*)\s+([^\s-]+)?/';
패턴 [^\s-]+는 "공백이나 하이픈이 아닌 문자의 연속"을 캡처합니다. 문제는 하이픈을 옵션 값 안에서도 경계로 인식한다는 것입니다. no-new-privileges:true에서 첫 번째 하이픈을 만나는 순간 캡처가 끝나버려 no만 남는 것이었습니다.
PR #9497 — 정규식 수정
수정은 간결했습니다.
// 수정 (PR #9497)
$regex = '/--(\w[\w-]*)\s+((?!--)[^\s]+)?/';
((?!--)[^\s]+)?로 바꿨습니다. 하이픈 자체는 값의 일부로 허용하되, --로 시작하는 새 옵션 플래그만 경계로 인식하는 것입니다. 이 수정으로 --security-opt no-new-privileges:true가 정상적으로 파싱됩니다.
돌이켜보면 직전에 올린 PR #9477(HEALTHCHECK 파싱 수정)의 경험이 도움이 되었습니다. Coolify의 컨트리뷰션 프로세스 — next 브랜치로 PR을 보내야 한다든가, quality check bot의 조건이라든가 — 를 이미 알고 있었기에 이번에는 훨씬 수월했습니다. 첫 번째 PR에서 삽질한 덕분에 두 번째 PR은 한 번에 통과했습니다.
Next.js 쪽도 빈틈을 좁힙니다
Coolify와 Docker 레벨의 방어를 마쳤으니, 애플리케이션 레벨도 점검했습니다.
보안 응답 헤더 추가
next.config.mjs에 보안 관련 HTTP 헤더를 설정했습니다.
// next.config.mjs 보안 헤더
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
}];
}
각각의 역할은 이렇습니다.
| 헤더 | 역할 |
|---|---|
X-Frame-Options: DENY |
클릭재킹 방지 — iframe 삽입 차단 |
X-Content-Type-Options: nosniff |
MIME 타입 스니핑 방지 |
Referrer-Policy |
외부 사이트로 전체 URL 노출 방지 |
Permissions-Policy |
카메라, 마이크, 위치 정보 API 비활성화 |
블로그에 카메라나 마이크 접근이 필요할 리 없으니, 아예 브라우저 API 레벨에서 막아두는 것이 맞습니다.
이미지 프록시 와일드카드 제한
Next.js의 next.config.mjs에서 이미지 최적화 도메인 설정도 수정했습니다. 기존에는 hostname: '**'으로 모든 외부 이미지를 허용하고 있었습니다.
Next.js의 /_next/image 엔드포인트는 외부 URL의 이미지를 프록시하여 최적화합니다. 와일드카드를 허용하면 이 엔드포인트가 SSRF(Server-Side Request Forgery) 벡터가 될 수 있습니다. 공격자가 내부 네트워크의 리소스를 이 프록시를 통해 요청할 수 있는 것입니다.
실제로 사용하는 CDN인 ik.imagekit.io만 허용하도록 변경했습니다. 필요한 것만 열어두는 것이 원칙입니다.
적용한 방어 체크리스트
이번 대응에서 적용한 조치들을 정리합니다. Coolify로 셀프호스팅하고 있다면 참고가 될 수 있습니다.
| 계층 | 조치 | 목적 |
|---|---|---|
| 네트워크 | iptables DOCKER-USER 체인에 마이닝 풀 포트 DROP |
채굴 통신 차단 |
| 네트워크 | iptables-save로 규칙 영속화 |
재부팅 후에도 유지 |
| 컨테이너 | --cap-drop ALL |
불필요한 커널 권한 제거 |
| 컨테이너 | --security-opt no-new-privileges:true (PR 머지 후) |
권한 상승 차단 |
| 애플리케이션 | 보안 응답 헤더 4종 추가 | 클릭재킹, MIME 스니핑 방지 |
| 애플리케이션 | 이미지 프록시 와일드카드 제거 | SSRF 벡터 제거 |
| 인프라 | 취약 버전 즉시 업그레이드 (Next.js 15.1.3 → 15.5.14) | CVE 패치 적용 |
--read-only와 --tmpfs noexec는 Coolify가 아직 지원하지 않아서 적용하지 못했습니다. 향후 $mapping에 추가되면 적용할 예정입니다.
시리즈를 마치며
빌드 타임아웃이라는 사소한 실패에서 시작된 이야기가, 크립토마이너 발견, CVE 분석, 보안 강화, 그리고 오픈소스 기여까지 이어졌습니다. 처음부터 이런 여정을 계획한 것은 아니었습니다.
이번 경험에서 가장 크게 느낀 것은, 패치를 커밋하는 것과 실제로 배포하는 것은 다른 일이라는 점입니다. Next.js 15.5.14는 취약점이 패치된 버전이었지만, 제 서버에 올라가 있던 건 15.1.3이었습니다. 코드베이스에 최신 버전이 명시되어 있어도, 그것이 운영 서버에 배포되지 않으면 의미가 없습니다. 빌드 실패를 방치한 사이에 마이너가 들어왔다는 사실이 그 차이를 선명하게 보여주었습니다.
셀프호스팅은 자유도가 높은 만큼 책임도 전부 자기 몫입니다. Vercel이나 Netlify에 올렸다면 플랫폼이 자동으로 보호해주었을 부분들 — 컨테이너 격리, 네트워크 정책, 보안 헤더 — 을 셀프호스팅에서는 하나하나 직접 설정해야 합니다. 그 과정이 번거롭지만, 동시에 인프라의 각 계층이 어떤 역할을 하는지 체감할 수 있는 기회이기도 했습니다.
Coolify는 셀프호스팅을 편하게 만들어주는 좋은 도구이지만, 보안 옵션 지원은 아직 부족한 부분이 있습니다. --read-only, --tmpfs가 무시되는 것이나, --security-opt의 파싱 버그 같은 것들입니다. 다만 오픈소스이기 때문에, 부족한 부분을 직접 고치고 기여할 수 있다는 점이 Coolify를 계속 쓰게 되는 이유이기도 합니다.
돌이켜보면, 빌드 타임아웃이라는 사소한 실패가 없었다면 크립토마이너를 발견하지 못했을 수도 있습니다. 실패를 방치하지 않고 원인을 추적하는 습관이, 때로는 예상치 못한 곳으로 이끌어준다는 것을 다시 한번 느꼈습니다.
관련 글
CVE-2025-55182 (React2Shell) — Next.js 15.x 비인증 RCE, 우리가 뚫린 이유
배포된 커밋의 Next.js 버전은 15.1.3이었습니다. pnpm-lock.yaml에는 이미 deprecated 경고가 적혀 있었고, 보안 패치 커밋은 존재했지만 배포되지 않은 채 방치되어 있었습니다. CVE-2025-55182의 공격 체인을 추적하며, 패치와 배포 사이의 간극이 얼마나 치명적인지 되짚어봅니다.
배포 실패 로그를 추적하다 크립토마이너를 발견한 이야기
Coolify에서 NestJS 배포가 타임아웃으로 실패했습니다. 서버 리소스를 확인하려고 docker stats를 열었는데, Next.js 프론트엔드 컨테이너가 CPU 123%, 메모리 2.41GB를 점유하고 있었습니다. 컨테이너 안에서 발견한 정체불명의 바이너리와 마이닝 풀 연결까지, 배포 실패에서 크립토마이너를 발견하기까지의 추적 기록입니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.