⚛️ Stuxnet — 에어갭을 넘어 이란 원심분리기를 파괴한 세계 최초의 사이버 무기 (2010)

2026-09-04 · Malware Deep-Dive

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

Stuxnet은 2010년 6월 17일 벨라루스의 소형 백신 회사 VirusBlokAda의 엔지니어 Sergey Ulasen이 발견한 웜이다. 이란 고객의 PC가 원인 모를 재부팅을 반복한다는 신고를 추적하다가, 인터넷과 단절된(에어갭) 원자력 시설까지 USB로 침투해 PLC(산업제어 장비)를 직접 조작하는 악성코드라는 충격적 실체가 드러났다. "산업 시스템을 정탐·파괴하는 최초의 악성코드"이자 "최초의 PLC 루트킷"으로 기록된다.

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

스턱스넷의 전파 설계는 "넓게 퍼뜨린 뒤, 정확히 한 곳만 때린다"는 이중 구조다. Windows 상에서는 무차별적으로 퍼지지만, 진짜 페이로드는 극히 특수한 산업 설비에서만 발동한다.

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

스턱스넷은 약 500KB(당시 기준 대형) 크기에 C/C++ 등 여러 언어로 작성됐으며, ① 웜(메인 페이로드 실행) ② .lnk 트리거 ③ 루트킷(파일·프로세스 은닉) 3개 모듈로 구성된다. 공격은 "Windows 장악 → Step7/WinCC 감염 → PLC 사보타주"의 3층 구조다.

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

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

⚠️ 안전 규칙: 아래 코드는 실제 스턱스넷 페이로드의 복사본이 아니라 "동작 원리만 재현한 교육용 모의 코드"다. 실제 .lnk 악성코드 생성, 실제 Windows 익스플로잇, 실제 PLC/인버터 통신, 실제 파일 파괴는 일절 포함하지 않으며, 공격 대상은 가상의 로컬 실습 환경(인메모리 가상 LNK 바이너리, 가상 PLC 객체, 수학적 모델)을 전제로 한다. 아래 코드는 모두 실제 실행해 출력을 확인했다.

PoC 1 — LNK 0-day(CVE-2010-2568) 동작 원리: 바로가기 파일의 "아이콘 위치" 필드가 공격자 제어 값이며, 취약한 셸은 이를 검증 없이 DLL 로드로 연결한다는 핵심을 재현한다. USB를 열기만 해도 감염되는 이유가 보인다.

#!/usr/bin/env python3
"""
PoC 1 — Stuxnet LNK 취약점(CVE-2010-2568, MS10-046) 동작 원리 모의
- Windows 셸은 .lnk(바로가기) 파일의 '아이콘 경로'를 신뢰하고,
  폴더를 열어 아이콘을 렌더링하는 과정에서 그 경로를 추가 검증 없이 DLL로 로드했다.
  -> 사용자가 바로가기를 '실행'하지 않아도, 보기만 해도 코드 실행이 되는 0-day.
- 아래는 실제 .lnk 바이너리를 만들지 않고, LNK 파서가 아이콘 경로를 어떻게
  해석하는지를 가상의 로컬 실습 데이터로 재현한다. 실제 시스템 공격 코드 없음.
"""
import struct

def build_fake_lnk(icon_location: str) -> bytes:
    """Shell Link(.lnk) 파일의 핵심 필드를 흉내 낸 가상 바이너리 (교육용)."""
    hdr = struct.pack("<I", 0x0000004C)          # 0x4C = 'L' (lnk 매직)
    flags = struct.pack("<I", 0x00000020)         # HasIconLocation 플래그
    icon = icon_location.encode("utf-16-le")       # 아이콘 경로(공격자 제어)
    return hdr + flags + struct.pack("<H", len(icon)) + icon

def parse_lnk(data: bytes):
    magic = struct.unpack("<I", data[:4])[0]
    assert magic == 0x4C
    flags = struct.unpack("<I", data[4:8])[0]
    if not (flags & 0x20):
        return None
    n = struct.unpack("<H", data[8:10])[0]
    return data[10:10 + n].decode("utf-16-le")

# (1) 공격자 USB에 들어 있던 악성 .lnk — 아이콘 경로가 공유 폴더의 DLL을 가리킴
malicious = build_fake_lnk(r"\\192.0.2.10\share\evil.dll")
icon_path = parse_lnk(malicious)
print("[1] Explorer가 USB 폴더를 '열자' .lnk 파싱 -> 아이콘 경로 추출(클릭 없음)")
print("    extracted IconLocation :", icon_path)

# (2) 취약한 shell32 동작 모의: 경로를 검증 없이 LoadLibrary에 전달
print("[2] 취약 패치 이전(MS10-046 이전) shell32 동작 모의")
print("    LoadLibrary('" + icon_path + "')  ->  DLL 코드 실행! (원격 공유에서도 허용)")

# (3) MS10-046 패치 후: 원격/검증 안 된 경로 차단
print("[3] MS10-046 패치 적용 후 모의")
if icon_path.startswith("\\\\") or not icon_path.lower().endswith(".dll"):
    print("    BLOCKED: 원격 경로의 아이콘 DLL 로드 거부 -> 웜 침투 실패")

※ 실제 취약점은 shell32가 아이콘 경로를 LoadLibrary에 넘기는 과정에서 원격 경로까지 허용한 데 있었다. 위 코드는 그 데이터 흐름만 모의한 것이다.

# 실행 결과
[1] Explorer가 USB 폴더를 '열자' .lnk 파싱 -> 아이콘 경로 추출(클릭 없음)
    extracted IconLocation : \\192.0.2.10\share\evil.dll
[2] 취약 패치 이전(MS10-046 이전) shell32 동작 모의
    LoadLibrary('\\192.0.2.10\share\evil.dll')  ->  DLL 코드 실행! (원격 공유에서도 허용)
[3] MS10-046 패치 적용 후 모의
    BLOCKED: 원격 경로의 아이콘 DLL 로드 거부 -> 웜 침투 실패

PoC 2 — s7otbxdx.dll 교체형 PLC MITM: 공격자가 엔지니어링 스테이션의 통신 라이브러리를 감염본으로 바꾸면, 운영자는 HMI에서 "정상"만 보게 된다는 눈속임 구조를 가상 PLC 객체로 재현한다.

#!/usr/bin/env python3
"""
PoC 2 — Stuxnet의 PLC 통신 MITM(s7otbxdx.dll 교체) 동작 원리 모의
- Stuxnet은 Step7/WinCC 엔지니어링 소프트웨어가 PLC(S7-300)와 통신할 때
  사용하는 라이브러리 s7otbxdx.dll을 감염된 DLL로 교체했다.
- 그 결과: 공격 코드가 PLC(DB890 블록)에 주입되어도, 운영자가 HMI로
  '읽기'를 하면 공격자는 미리 녹음해 둔 '정상 값'을 돌려줘 눈에 안 띈다.
- 아래는 가상의 PLC 객체 1개로 이 '속임수 읽기/실제 쓰기'를 재현한다.
"""
import time

class FakePLC:
    """가상 S7-300 PLC: 원심분리기 구동 주파수 세트포인트 보관 (교육용 로컬 객체)."""
    def __init__(self):
        self.db890 = {"speed_hz": 1064, "mode": "NORMAL", "alarm": 0}

class Step7Station:
    """엔지니어링 스테이션 — 교체된 s7otbxdx.dll을 경유해 PLC와 통신한다고 가정."""
    def __init__(self, plc):
        self.plc = plc
        self.recorded_normal = dict(plc.db890)   # DLL 교체 전에 '녹음'해 둔 정상값

    def s7otbxdx_read(self, addr):
        """감염된 s7otbxdx.dll의 read: 실제 값 대신 녹음본을 돌려준다(MITM)."""
        return dict(self.recorded_normal)

    def s7otbxdx_write(self, addr, value):
        """write만 실제 PLC에 전달한다(공격 페이로드는 이 경로로 주입)."""
        self.plc.db890.update(value)

# 1) 공격: 교체된 DLL 경유로 PLC에 과속 명령 주입 (운영자는 모름)
plc = FakePLC(); st = Step7Station(plc)
print("[1] 공격 코드가 PLC DB890에 과속 프로파일 주입 (write는 정상 통과)")
st.s7otbxdx_write("db890", {"speed_hz": 1410, "mode": "ATTACK", "alarm": 1})
print("    PLC 실제 상태:", plc.db890)

# 2) 운영자가 HMI로 상태를 읽으면? -> 감염된 DLL이 '녹음본'을 응답
print("[2] 운영자가 HMI로 PLC 상태 읽기 요청 (사람의 눈으로는 정상!)")
print("    운영자에게 표시된 값:", st.s7otbxdx_read("db890"))
print("    -> 실제로는", plc.db890["mode"], "모드,", plc.db890["speed_hz"], "Hz로 가동 중")

# 3) 방어 관점: 라이브러리 무결성 검증이 있었다면?
import hashlib
good_hash = "9f8e7d6c5b4a39281706f5e4d3c2b1a0"   # 원본 DLL 해시(가상)
infected_file = "s7otbxdx.dll (감염본)"
print("[3] 방어: DLL 화이트리스트 해시 검증 적용 시 ->", good_hash != hashlib.md5(infected_file.encode()).hexdigest() and "탐지됨(교체 의심) -> 차단")
# 실행 결과
[1] 공격 코드가 PLC DB890에 과속 프로파일 주입 (write는 정상 통과)
    PLC 실제 상태: {'speed_hz': 1410, 'mode': 'ATTACK', 'alarm': 1}
[2] 운영자가 HMI로 PLC 상태 읽기 요청 (사람의 눈으로는 정상!)
    운영자에게 표시된 값: {'speed_hz': 1064, 'mode': 'NORMAL', 'alarm': 0}
    -> 실제로는 ATTACK 모드, 1410 Hz로 가동 중
[3] 방어: DLL 화이트리스트 해시 검증 적용 시 -> 탐지됨(교체 의심) -> 차단

PoC 3 — 원심분리기 과속 사이클 파손 시뮬레이션: 1,064Hz 정상 운전 중 1,410Hz 과속을 반복 주입할 때 누적 피로로 로터가 파손되는 과정을 단순 모델로 시뮬레이션한다. (교육용 단순화 — 실제 물리·파손 임계값과 다를 수 있다.)

#!/usr/bin/env python3
"""
PoC 3 — 원심분리기 과속/감속 사이클 사보타주 시뮬레이션 (단순 교육 모델)
- 공개 분석(ISIS 2010-12, Symantec Dossier)에 따르면 스턱스넷은 IR-1 원심분리기
  구동 주파수를 정상 1,064Hz -> 1,410Hz(약 15분)로 올렸다가 되돌리는 사이클을
  반복해, 과속/과도 진동으로 로터를 파손시키도록 설계됐다.
- 아래 모델: f <= 1,064 정상, 1,064 < f <= 1,300 위험, f > 1,300 과속 누적 피로.
  스트레스는 (f/f_rated)^2 에 비례한다고 가정한 '교육용 단순화'이며 실제 물리와 다름.
"""
RATED = 1064.0

def stress(freq_hz: float) -> float:
    """주파수 비의 제곱에 비례하는 기계적 스트레스(단순 모델)."""
    return (freq_hz / RATED) ** 2

def simulate(days: int):
    t = 0          # 누적 가동 일수
    dmg = 0.0      # 누적 피로 손상 (1.0 = 파손)
    cycles = 0
    while t < days:
        # 공격 사이클: 15분간 1,410Hz 과속 -> 정상 복귀 (하루 2회 발생 가정)
        for _ in range(2):
            dmg += 0.012 * (stress(1410.0) - 1.0)   # 과속 구간 추가 손상
            dmg += 0.006 * (stress(1064.0) * 1.0)   # 감속/재가속 통과 시 진동 손상
            cycles += 1
            if dmg >= 1.0:
                print(f"  t={t:.0f}일차: 누적 손상 {dmg:.2f} -> 로터 파손(과속/공진 추정)")
                return t, cycles
        dmg *= 0.999   # 미세한 자기 회복(균열은 안 낫지만 진동은 줄 수 있음) — 단순화
        t += 1
    return days, cycles

print("[정상 운전] 1,064Hz 연속 가동 시 스트레스: %.4f (설계 기준 1.0)" % stress(1064.0))
print("[공격 프로파일] 1,410Hz(15분) 스트레스: %.4f (설계 대비 %.0f%% 초과)"
      % (stress(1410.0), (stress(1410.0) - 1.0) * 100))
print("[시뮬레이션] 하루 2회 과속 사이클 주입 시 파손까지 걸린 시간:")
f_day, n = simulate(120)
print(f"  -> {f_day}일 경과, {n}회 사이클 만에 파손 임계 도달")
print("[참고] ISIS 보고는 2009-11 ~ 2010-01 사이 나탄츠 IR-1 약 1,000기(약 10%) 파손 추정")
# 실행 결과
[정상 운전] 1,064Hz 연속 가동 시 스트레스: 1.0000 (설계 기준 1.0)
[공격 프로파일] 1,410Hz(15분) 스트레스: 1.7561 (설계 대비 76% 초과)
[시뮬레이션] 하루 2회 과속 사이클 주입 시 파손까지 걸린 시간:
  t=33일차: 누적 손상 1.01 -> 로터 파손(과속/공진 추정)
  -> 33일 경과, 68회 사이클 만에 파손 임계 도달
[참고] ISIS 보고는 2009-11 ~ 2010-01 사이 나탄츠 IR-1 약 1,000기(약 10%) 파손 추정

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

출처

함께 읽기: 💀 WannaCry (2017) — 스턱스넷 제작진으로 추정되는 Equation Group의 도구가 2017년 유출돼 EternalBlue로 이어진 연결고리 · 🦠 Mirai (2016) — "특정 대상 정밀 타격(스턱스넷)" vs "무차별 대규모 무기화(Mirai)"의 양극단

목록으로 돌아가기