☠️ NotPetya — '랜섬웨어'로 위장한 파괴형 와이퍼, M.E.Doc 공급망에서 시작돼 세계를 멈추다 (2017)

2026-09-08 · Malware Deep-Dive

1. 배경/역사 — 언제, 누가, 왜 유명한가

NotPetya는 2017년 6월 27일 우크라이나에서 시작돼 수 시간 만에 전 세계로 번진, 역사상 가장 파괴적인 사이버 공격으로 평가되는 악성코드다. 겉으로는 "파일을 잠그고 300달러를 요구하는 랜섬웨어"처럼 보였지만, 실제로는 복구가 구조적으로 불가능한 '와이퍼(wiper)'였다. 백악관 평가 기준 총 피해액 100억 달러(약 13조 원) 이상을 남긴 이 공격은, 러시아 GRU 산하 Sandworm(샌드웜) 그룹의 소행으로 공식 지목됐다.

2. 감염·전파 경로 — 어떻게 퍼졌나

NotPetya의 전파는 "공급망을 통한 1차 침투 → 자격 증명 탈취 기반 내부 확산 + SMB 취약점을 통한 무차별 확산"의 3중 구조였다. 우크라이나에 국한된 1차 침투가 어떻게 수 시간 만에 전 세계 60여 개국을 강타하는 웜이 됐는지가 이 사건의 핵심이다.

3. 동작 원리 상세 분석 — 코드/구조 레벨에서

NotPetya의 구조는 "전파(드로퍼) — 파괴(부트로더·MFT 암호화) — 위장(가짜 chkdsk·랜섬 메시지)" 3층으로 나뉜다. '랜섬웨어처럼 보이도록' 세심하게 위장했지만, 복구 경로를 의도적으로 전부 제거한 것이 기술적 핵심이다.

4. 피해 규모/영향 — 실제 사례 수치

5. PoC — 교육용 모의 코드로 동작 원리 재현

⚠️ 안전 규칙: 아래 코드는 실제 NotPetya 바이너리나 EternalBlue·Mimikatz의 실전 코드가 아니라 "동작 원리만 재현한 교육용 모의 코드"다. 실제 SMB 공격, 실제 자격 증명 탈취, 실제 디스크·MBR·파일 암호화는 일절 포함하지 않으며, 공격 대상은 가상의 로컬 실습 환경(메모리 속 가상 객체, 가상 호스트 목록, 가상 MFT 레코드)을 전제로 한다. 아래 코드는 모두 실제 실행해 출력을 확인했다.

PoC 1 — 공급망(업데이트 서버) 오염 구조: "정품 업데이트 채널이 장악되면 어떤 일이 벌어지는가"를 서명 검증 유무에 따라 대조한다. M.E.Doc 사태의 본질 — 공급망의 신뢰성이 무너지면 수만 대 PC가 '정품 업데이트'로 동시 감염된다는 점 — 을 가상 서버 객체로 재현한다.

#!/usr/bin/env python3
"""
PoC 1 — NotPetya의 M.E.Doc 공급망 오염 구조 모의 (교육용)

2017년 6월 27일, 우크라이나 회계·세무 소프트웨어 M.E.Doc의 업데이트 서버가
침해당했고(ESET 분석 결과 백도어가 최소 6주 전부터 존재), 정품 업데이트로
위장한 트로이 목마가 수만 개 기업의 '신뢰된 갱신 경로'를 타고 동시에
배포됐다. 이른바 '신뢰할 수 있는 업데이트 채널'이 단일 실패 지점이 된 사례다.

아래 코드는 네트워크 호출 없이 '가상의 업데이트 서버/클라이언트' 객체로
그 구조만 재현한다:
  1) (정상) 서명이 검증된 업데이트 -> 수락
  2) (공격) 버전 번호만 높고 서명이 없는 악성 업데이트 -> 서명 미검증
     클라이언트는 수락, 서명 검증 클라이언트는 거부
실제 M.E.Doc 소프트웨어·실제 서버를 대상으로 하지 않는다.
"""
# --- 가상의 업데이트 메타데이터 ---
def make_update(version: str, payload: str, signature: str | None):
    """payload: 'clean'이면 정상 패치, 'trojan'이면 악성 페이로드."""
    return {"version": version, "payload": payload, "signature": signature}

REAL_SIG = "SIG-VERIFIED"     # 정품 개발사만 가진 서명 키

class FakeUpdateServer:
    """공격자에게 장악당한 가상의 업데이트 서버."""
    def __init__(self):
        # 공격자는 서버를 장악했으므로 '광고 목록(매니페스트)'까지 조작할 수 있다
        self.updates = {
            "1.2.0": make_update("1.2.0", "clean", REAL_SIG),
            "1.2.1": make_update("1.2.1", "trojan", None),        # 서명 자체가 없음
            "1.2.2": make_update("1.2.2", "trojan", "FORGED-SIG"), # 위조 서명
            # 1.2.3(정상 패치)도 서버에 있지만, 공격자는 광고에서 빼버림
        }
        self.advertised = "1.2.2"   # 공격자가 광고하는 '최신' 버전

    def latest(self):
        # 공격자가 조작한 매니페스트가 광고하는 버전
        return self.advertised

class NaiveUpdateClient:
    """서명을 검증하지 않는 (2017년의 수많은 기업용 SW와 같은) 클라이언트."""
    name = "서명 미검증 클라이언트 (취약)"

    def __init__(self):
        self.installed = "1.2.0"

    def poll(self, server: FakeUpdateServer):
        latest = server.latest()
        print(f"  서버가 광고하는 최신 버전: {latest}")
        if latest > self.installed:
            upd = server.updates[latest]
            print(f"  버전 {latest} 발견 -> 다운로드 -> INSTALL (payload={upd['payload']})")
            self.installed = latest
            return upd["payload"] == "trojan"
        return False

class VerifyingUpdateClient:
    """업데이트 서명을 검증하는 클라이언트 (올바른 방어)."""
    name = "서명 검증 클라이언트 (안전)"

    def __init__(self):
        self.installed = "1.2.0"
        # 화이트리스트: 신뢰하는 서명 키
        self.trusted_keys = {REAL_SIG}

    def poll(self, server: FakeUpdateServer):
        latest = server.latest()
        print(f"  서버가 광고하는 최신 버전: {latest}")
        if latest > self.installed:
            upd = server.updates[latest]
            if upd["signature"] and upd["signature"] in self.trusted_keys:
                print(f"  버전 {latest} 서명 검증 OK -> INSTALL (payload={upd['payload']})")
                self.installed = latest
                return upd["payload"] == "trojan"
            else:
                print(f"  버전 {latest} 서명 검증 실패(sig={upd['signature']}) -> 거부")
                return False
        return False

if __name__ == "__main__":
    server = FakeUpdateServer()
    print("== 시나리오: 공급망 업데이트 서버가 장악된 경우 ==")
    print()
    print(f"[1] {NaiveUpdateClient.name}")
    infected = NaiveUpdateClient().poll(server)
    print(f"    -> 트로이 목마 설치됨? {infected}  (전 직원 PC가 '정품 업데이트'로 감염)")
    print()
    print(f"[2] {VerifyingUpdateClient.name}")
    infected2 = VerifyingUpdateClient().poll(server)
    print(f"    -> 트로이 목마 설치됨? {infected2}  (서명 검증으로 차단)")
    print()
    print("교훈: 공급망 공격에서 '서버를 믿는 것'은 충분하지 않다. "
          "코드 서명 검증이 있으면 서버가 장악돼도 악성 업데이트는 설치되지 않는다.")
# 실행 결과
== 시나리오: 공급망 업데이트 서버가 장악된 경우 ==

[1] 서명 미검증 클라이언트 (취약)
  서버가 광고하는 최신 버전: 1.2.2
  버전 1.2.2 발견 -> 다운로드 -> INSTALL (payload=trojan)
    -> 트로이 목마 설치됨? True  (전 직원 PC가 '정품 업데이트'로 감염)

[2] 서명 검증 클라이언트 (안전)
  서버가 광고하는 최신 버전: 1.2.2
  버전 1.2.2 서명 검증 실패(sig=FORGED-SIG) -> 거부
    -> 트로이 목마 설치됨? False  (서명 검증으로 차단)

교훈: 공급망 공격에서 '서버를 믿는 것'은 충분하지 않다. 코드 서명 검증이 있으면 서버가 장악돼도 악성 업데이트는 설치되지 않는다.

PoC 2 — '자격 증명 탈취 + SMB 취약점' 이중 전파 축: NotPetya가 내부망을 가로지르는 두 갈래 전파 (① 패치 안 된 PC는 EternalBlue로 무인증 점령 ② 패치된 PC라도 Mimikatz로 훔친 자격 증명으로 점령)를 가상의 호스트 4대로 시뮬레이션한다. 실제 네트워크 통신은 없다.

#!/usr/bin/env python3
"""
PoC 2 — NotPetya의 '이중 전파 축' 구조 모의 (교육용)

NotPetya는 한 PC에 들어온 뒤 두 갈래로 옆으로 퍼졌다:
  A축) EternalBlue(MS17-010, SMBv1) — 패치되지 않은 PC를 원격 점령
  B축) Mimikatz로 lsass에서 훔친 자격 증명 + PsExec/WMIC — '패치된' PC도
       관리자 계정을 탈취당하면 점령

아래 코드는 실제 SMB/네트워크 호출 없이 '가상의 내부망 호스트' 객체들에
대해 이 두 축의 조건부 로직만 재현한다. 실제 시스템은 일절 건드리지 않는다.
"""
from dataclasses import dataclass

@dataclass
class FakeHost:
    """실습용 가상 호스트 (실제 컴퓨터가 아님)."""
    name: str
    patched: bool              # MS17-010 패치 여부
    admin_pwd: str             # 관리자 계정 비밀번호 (가상)
    compromised: bool = False

# Mimikatz가 lsass 메모리에서 훔쳐낸 '것처럼' 만든 가상 자격 증명 풀
# (감염 PC에 로그인돼 있던 세션의 자격 증명이라고 가정)
STOLEN_PASSWORDS = {"P@ssw0rd", "Winter2026!"}

def axis_a_eternalblue(target: FakeHost) -> bool:
    """A축: 패치 전 호스트는 SMBv1 취약점으로 인증 없이 점령."""
    if target.patched:
        return False
    target.compromised = True
    return True

def axis_b_stolen_creds(target: FakeHost, creds) -> bool:
    """B축: 패치된 호스트라도 훔친 관리자 자격 증명이 통하면 점령."""
    if target.admin_pwd in creds:
        # 실제로는 원격 WMI/PsExec로 프로세스 실행 (여기서는 플래그만)
        target.compromised = True
        return True
    return False

if __name__ == "__main__":
    network = [
        FakeHost("PC-FIN-01", patched=False, admin_pwd="P@ssw0rd"),     # 패치 안 됨+흔한 암호
        FakeHost("SRV-DB-02", patched=True,  admin_pwd="P@ssw0rd"),     # 패치됨+훔친 풀에 있는 암호
        FakeHost("PC-RND-03", patched=True,  admin_pwd="Xk9#mQ2!vL"),   # 패치됨+고유 강한 암호
        FakeHost("DC-AD-01",  patched=True,  admin_pwd="R7$tY4@nPqW"),   # 도메인 컨트롤러(고유 강한 암호)
    ]
    print("== 감염 PC(PC-FIN-01 진입 가정)의 내부망 전파 시뮬레이션 ==")
    for host in network:
        via_a = axis_a_eternalblue(host)
        via_b = not via_a and axis_b_stolen_creds(host, STOLEN_PASSWORDS)
        how = ("A축 EternalBlue" if via_a else
               "B축 훔친 자격증명+PsExec" if via_b else "전파 실패")
        print(f"  {host.name}: patched={host.patched}, admin_pwd={host.admin_pwd} "
              f"-> 점령={host.compromised} ({how})")
    print()
    print("결론: ① 패치는 A축을 막지만 ② 자격 증명 탈취(Mimikatz)는 패치를 "
          "우회한다 -> '계정 보호 + 네트워크 세분화'가 함께 필요하다.")
# 실행 결과
== 감염 PC(PC-FIN-01 진입 가정)의 내부망 전파 시뮬레이션 ==
  PC-FIN-01: patched=False, admin_pwd=P@ssw0rd -> 점령=True (A축 EternalBlue)
  SRV-DB-02: patched=True, admin_pwd=P@ssw0rd -> 점령=True (B축 훔친 자격증명+PsExec)
  PC-RND-03: patched=True, admin_pwd=Xk9#mQ2!vL -> 점령=False (전파 실패)
  DC-AD-01: patched=True, admin_pwd=R7$tY4@nPqW -> 점령=False (전파 실패)

결론: ① 패치는 A축을 막지만 ② 자격 증명 탈취(Mimikatz)는 패치를 우회한다 -> '계정 보호 + 네트워크 세분화'가 함께 필요하다.

PoC 3 — '랜섬웨어 위장 와이퍼'의 파괴 구조: 진짜 랜섬웨어(복구 키 보관)와 NotPetya형 와이퍼(키 폐기)의 차이를 가상 MFT 객체로 대조하고, 실제로 보고된 perfc '백신' 파일의 동작 조건도 함께 보여준다. 실제 디스크는 건드리지 않는다.

#!/usr/bin/env python3
"""
PoC 3 — '랜섬웨어로 위장한 와이퍼' NotPetya의 암호화 구조 모의 (교육용)

NotPetya는 겉으로는 '파일을 잠그고 $300를 요구하는 랜섬웨어'처럼 보였지만,
암호화 루틴이 원복 불가능하게 설계되어 사실상 와이퍼였다:
  1) 부팅 시 MBR 부트로더를 덮어써 강제 재부팅 유도
  2) NTFS MFT(마스터 파일 테이블)를 Salsa20으로 암호화 -> 파일명 트리 소실
  3) 'chkdsk'로 위장한 화면을 띄우며 암호화 진행
  4) 단일 고정 BTC 지갑에 $300 요구 + 복구용 이메일은 이미 정지
     -> '지불해도 복구 불가능' = 금전 목적이 아닌 파괴 목적

아래 코드는 가상의 MFT 레코드 목록을 대상으로 '진짜 랜섬웨어(복구 키 보관)'와
'NotPetya형 와이퍼(키 폐기)'의 차이만 재현한다. 실제 디스크·파일은 건드리지 않는다.
"""

class FakeMFT:
    """가상의 NTFS MFT 레코드 (파일명 -> 논리 블록). 실제 디스크와 무관."""
    def __init__(self, files):
        self.files = {f: i for i, f in enumerate(files)}  # name -> record idx

class RealRansomware:
    """비교군: 진짜 랜섬웨어 (Petya 2016 등) — 복구 키를 공격자 C2에 저장."""
    name = "진짜 랜섬웨어 (금전 목적)"

    def encrypt(self, mft: FakeMFT):
        key = "AES-KEY-SAVED"          # 복구 키를 어딘가에 보관
        print(f"  MFT {len(mft.files)}개 레코드 암호화 (키: {key})")
        return key                      # -> 지불하면 복구 가능

class NotPetyaWiper:
    """NotPetya형: 복구 키를 만들지 않거나 즉시 폐기."""
    name = "NotPetya형 와이퍼 (파괴 목적)"

    def encrypt(self, mft: FakeMFT):
        # Salsa20 스트림 암호로 MFT를 덮어쓰지만, 복호화 키를 어디에도 저장하지 않음
        for name in mft.files:
            mft.files[name] = "0xDEADBEEF(encrypted, key discarded)"
        print(f"  MFT {len(mft.files)}개 레코드 암호화 후 키 즉시 폐기")
        return None                     # 복구 수단 없음

if __name__ == "__main__":
    print("== 대조: 같은 '암호화'라도 목적에 따라 결과가 다르다 ==")
    for cls in (RealRansomware, NotPetyaWiper):
        mft = FakeMFT(["report.xlsx", "db_backup.bak", "contract.pdf", "photo.jpg"])
        print(f"[{cls.name}]")
        key = cls().encrypt(mft)
        if key:
            print(f"  -> 복구 키 보관됨: {key} — 피해자는 돈을 내면(이론상) 복구 가능")
        else:
            print("  -> 복구 키 없음 = '지불'은 사기극, 데이터는 영구 소실 (와이퍼)")
        print()

    # '백신(perfc)' 우회 가능 여부: NotPetya 페이로드는 Windows 폴더에
    # perfc/perfc.dat/perfc.dll 파일이 있으면 실행을 중단한다는 분석이 있었다.
    def vaccine_check(files_in_windows_dir):
        vaccine = any(f in ("perfc", "perfc.dat", "perfc.dll")
                      for f in files_in_windows_dir)
        return vaccine
    print("== '백신' 파일 존재 여부에 따른 페이로드 실행 ==")
    print(f"  백신 없음          -> 실행? {not vaccine_check(['winlogon.exe'])}")
    print(f"  perfc.dat 존재     -> 실행? {not vaccine_check(['perfc.dat'])}")
    print("  (읽기 전용 perfc 파일 생성이 임시 방어책으로 제안된 사건이었다)")
# 실행 결과
== 대조: 같은 '암호화'라도 목적에 따라 결과가 다르다 ==
[진짜 랜섬웨어 (금전 목적)]
  MFT 4개 레코드 암호화 (키: AES-KEY-SAVED)
  -> 복구 키 보관됨: AES-KEY-SAVED — 피해자는 돈을 내면(이론상) 복구 가능

[NotPetya형 와이퍼 (파괴 목적)]
  MFT 4개 레코드 암호화 후 키 즉시 폐기
  -> 복구 키 없음 = '지불'은 사기극, 데이터는 영구 소실 (와이퍼)

== '백신' 파일 존재 여부에 따른 페이로드 실행 ==
  백신 없음          -> 실행? True
  perfc.dat 존재     -> 실행? False
  (읽기 전용 perfc 파일 생성이 임시 방어책으로 제안된 사건이었다)

6. 방어 방법/교훈 — 어떻게 막았고, 무엇을 배우나

출처

함께 읽기: 💀 WannaCry (2017) — 같은 NSA 유출 무기 EternalBlue를 5월에 사용한 자가전파형 랜섬웨어 · ⚛️ Stuxnet (2010) — 국가가 만든 파괴형 사이버 무기의 원형 · 🪱 Conficker (2008) — SMB 취약점 웜의 고전

목록으로 돌아가기