랜섬웨어 분석 보고서

SINOBI 랜섬웨어 암호화 및 복호화 기술 분석 보고서

zeusec 2025. 12. 11. 15:31

1. 랜섬웨어 개요

  • Sinobi 는 RaaS 모델로 2025년 6월말 ~ 7월 초 등장한 랜섬웨어 조직으로 첫 피해 사례는 2025년 7월에 확인됐으며 여러 분석 보고서 에서 Sinobi 는 Lynx 또는 INC 랜섬웨어의 후속 변종으로 추정됨
  • 주요 표적은 미국과 캐나다, 호주 등 서구권의 중대형 제조 및 비즈니스 서비스와 의료 및 금융기관을 겨냥하고 있고 정부 기관과 동유럽 지 역은 법적, 정치적 대응을 피하기 위해 사용하지 않으며 다양한 분석 보고서에서 주요 침투 벡터에는 공격자들이 초기 접근 중개 (IAB) 로부터 도난 계정 , 피싱 이메일 , VPN 및 방화벽 취약점,  3차 공급망 침해등을 통해 침투를 시도를 함
  • Sinobi 의 Raas 의 경우는 데이터 브로커형 그룹으로 대량의 데이터 탈취 후 .onion 사이트에서 유출을 위협하는 방식 즉 , 이중갈취 랜섬웨 어로 파일 암호화하기 전에 데이터를 유출도 하여 이중으로 갈취를 취하는 형태를 띈다.

2. 식별 정보

  • Malware Family: Sinobi
  • Sample Hash
    • MD5: 08759d9ea2712d693891c870bbebbde3
    • SHA1: 00454046c6be47839226fc55da5bc492e5d4f99c
    • SHA256: d4919a7402d7ae02516589fbdfb3cc436749544052843a37b5d36ac4b7385b18
    • Collection Path: Malware Bazaar (Sample Link)
  • Target: Window 기반 시스템

3. 분석 환경

  • 사용 분석 도구 : IDA, xdbg64, VMware(Window 11)

4. 암호화 기술 분석

4.1 IOCP 기반 파일 암호화 엔진

  • Sinobi 랜섬웨어의 경우 I/O Completion Port(IOCP) 기반 파일 암호화 작업을 수행하는 스레드 함수 ( 파일 암호화 Worker Thread) 가 존재하여 Sinobi 랜섬웨어의 핵심 암호화 엔진으로 멀티스레드 기반 고속 암호화를 위한 비동기 I/O 패턴을 사용하고 있다 .
  • 주요 기능은 다음과 같다.

4.1.1 IOCP 작업 큐를 초기화 및 처리

[그림 1] IOCP 작업 대기 및 처리 (sub_140006D40)

4.1.2 상태 머신 기반 암호화 프로세스

[그림 2] State 0: 파일 읽기 준비 및 초기화 (sub_140006D40)
[그림 3] State 1: 파일 데이터 읽기 완료 후 암호화 준비 (sub_140006D40)
[그림 4] State 2: 암호화 블록 쓰기 완료 처리 및 헤더 추가 (sub_140006D40)
[그림 5] State 3: 암호화된 데이터 검증(SINOBI 시그니처 확인) (sub_140006D40)
[그림 6] State 4: 쓰기 작업 완료 확인 (sub_140006D40)
[그림 7] State 5: 파일 타임스템프 복원 및 정리 (sub_140006D40)

 

[그림 8] State 6: 에러 처리 및 리소스 해제 (sub_140006D40)
[그림 9] State 7: 정상 종료 (sub_140006D40)

4.1.3 암호화 수행 부분

  • State 1에서 16byte 키스트림 블록을 생성하여 16byte 가 맞는지 확인하여 IV/Counter 를 v44 에 복사하여 sub_14001400 으로 key Stream 을 생성하고 생성된 키 스트림을 통해 평문과 XOR 하는 방식을 사용한다 .
    • ( 평문 ⊕ 키스트림 = 암호문은 CTR 모드의 핵심 )
  • 또한 IV 를 Little-endian 방식으로 마지막 바이트부터 1씩 증가하는 방식으로 Counter 처럼 사용하는것을 확인했다.

[그림 10] 파일 암호화 부분 (sub_140006D40)

4.1.4 암호화 알고리즘

  • sub_14001400에서 S-Box, ShiftRows, MixColumns, AddRoundKey, 10 Round를 통해AES-128 블록 암호 구현된 것을 확인할 수 있었다.

[그림 11] AES S-Box 치환 (sub_140001400)
[그림 12] byte_14002B770 S-Box (sub_140001400)

  • ShifitRows(4×4 상태 행렬(State Matrix)의 각 행을 왼쪽으로 순환 시프트(circular left shift))를 하며 if(i == 10)에서 10번 라운드를 도는 것을 확인 함(AES -128임을 확인 가능)

[그림 13] ShifitRows_4×4 상태 행렬 (sub_140001400)

  • AES MixColumns의 핵심 Galois Field 연산을 확인

[그림 14] MixColumns (sub_140001400)

4.1.5 파일 암호화 시작

  • sub_140007150에서는 암호화할 원본 파일을 열어 읽기 전용을 제거하고 읽기/쓰기 권한으로 파일을 열어 파일 크기를 확인하여 빈 파일인 경우에는 암호화를 진행하지 않게한다.
  • 하드코딩된 공격자 공개키 로드하고 sub_140009030 함수를 이용해 피해자의 임시 개인키와 임시 공개키를 생성하여 ECDH 공유 비밀을 생성하고 SHA-512를 통해 64byte인 공유 비밀과 피해자의 공개키를 생성한다.
  • SHA-512 해시의 첫 16bytes를 AES 키로 사용하고 해시의 다음 16byte를 Nonce로 사용하는 것을 확인 할 수 있었다.

[그림 15] 파일 열기 및 검증 (sub_140007150)
[그림 16] 공격자의 공개키 및 키 생성 함수 (sub_140007150)

  • sub_140007150 암호화 헤더 구조
오프셋 크기 용도 로드되는 위치 설명
0x00 16 바이트 피해자 공개키 [0:16] v59에 로드 32 bytes 공개키의 전반부
0x10 16 바이트 피해자 공개키 [16:32] v60에 로드 32 bytes 공개키의 후반부
0x20 16 바이트 SHA-512(피해자 공개키) [0:16] v61에 로드 64 bytes 해시의 1/4
0x30 16 바이트 SHA-512(피해자 공개키) [16:32] v62에 로드 64 bytes 해시의 1/4
0x40 16 바이트 SHA-512(피해자 공개키) [32:48] v63 에 로드 64 bytes 해시의 1/4
0x50 16 바이트 SHA-512(피해자 공개키) [48:64] v64 에 로드 64 bytes 해시의 1/4
0x60 16 바이트 SINOBI 시그니처 + 메타데이터 String1 / 헤더 시그니처 및 설정

4.1.6 키 파생 함수 (KDF)

  • 하드코딩된 공격자의 공개키를 통해 피해자의 임시 개인키(32bytes)를 CryptoGenRandom으로 생성하여 Curve25519 글램핑을 하고 피해자의 임시 개인키와 * G(베이스 포인트)와 Curve25519 스칼라 곱셈을 통해 피해자 임시 공개키를 생성한다.
  • ECDH 공유 비밀을 생성할 때에는 피해자의 개인키와 공격자의 공개키를 통해 32bytes 를 만들고 해당 공유 비밀을 SHA-512기반 키 파생한다.
  • ECDH 공유 비밀을 SHA-512를 통해 64bytes로 확장하여 16byte는 AES Key와 그 다음 16byte는 Nonce로 사용한다.
  • CryptGenRandom(32byte) → Curve25519 클램핑(32byte) → ECDH 공유 비밀 생성(32byte) → SHA-512(64byte) → AES Key(16byte) / Nonce(16byte)
  • 암호화 된 파일을 통한 KDF 검증의 경우 부록 7.1을 참고

[그림 17] 피해자의 초기 개인키 생성(sub_140009030)
[그림 18] 키 가공 과정(sub_140009030)
[그림 19] SHA512 초기 상수H0 ~ H7 확인 (sub_140009030)

4.1.7 파일 암호화 최종 흐름도

[그림 20] SINOBI 랜섬웨어 암호화 흐름도

5. 복호화 기술 분석

5.1 CryptGenRandom 후킹 로그에서 개인키 후보 추출

HEX_RE = re.compile(r"Data:\s*([0-9A-Fa-f]+)")

5.2 Curve25519 개인키 Clamp 적용

lb[0] &= 0xF8
lb[31] &= 0x7F
lb[31] |= 0x40

5 .3 공격자 공개키 기반 ECDH

DEFAULT_ATTACKER_PUB_B64 = "Il2uG3L5S9oCrKxvozNzWSynv8ZWxN09+9w8aLpAhBE="
attacker_pub = base64.b64decode(attacker_pub_b64)

shared = priv_obj.exchange(
    x25519.X25519PublicKey.from_public_bytes(attacker_pub)
)

5. 4 공유 비밀 AES Key/IV 파생 방식 다중 시도

h512 = sha512(shared).digest()
("sha512", h512[:16], h512[16:32])

h256 = sha256(shared).digest()
("sha256", h256[:16], h256[16:32])

("raw", shared[:16], shared[16:32])

5.5 IV 변형 시도

("base", iv_base)
("reversed", iv_base[::-1])
("xor_md5", iv_base ^ md5(filename))
("xor_sha1", ...)
("xor_sha256", ...)
("xor_filesize", ...)
("attacker_pub0", attacker_pub[:16])
("attacker_pub1", attacker_pub[16:32])
("name_padded", filename padded)

5.6 AES-CTR 복호화 및 오프셋

  • SINOBI의 경우 앞 116byte에는 일정 크기의 구조체/메타데이터를 두고 실제 데이터 영역만 AES-CTR로 처리
for offset in offsets:  # default (0, 116)
    dec = aes_ctr_decrypt(enc[offset:], key, ivv)
    data = enc[:offset] + dec

6. 요약 및 결론

6.1 암호화 워크플로우 요약

  • 분석된 전체 암호화 과정은 위 도식과 같이 파일 Read부터 Encrypted File(암호화)까지 순차적으로 진행되며, 각 단계별 상세 동자은 다음과 같다.
    • Step 1. 대상 파일 선별 및 준비 암호화할 파일을 열고 읽기 전용 속성 제거하여 읽기/쓰기 권한으로 핸들을 확보하고 파일 크기를 확인하여 빈 파일은 암호화를 제외한다.
    • Step 2. 키 교환 및 대칭키(AES-CTR) 재료 생성 CryptoGenRandom으로 피해자 임시 개인키(32 byte)를 생성하여 Curver25519 글램핑을 적용하고 하드코딩된 공격자 공개키를 로드하여 X25519 ECDH를 수행하여 공유 비밀(32 bytes)를 생성한다.
    • Step 3. KDF(키 파생)의 경우 Step2에서 생성된 공유 비밀을 SHA-512로 64bytes 확장하여 앞 16byte를 AES의 Key로 그 다음 16byte를 IV로 사용
      • AES Key = SHA-512(shared)[0:16]
      • IV/Counter = SHA-512(shared)[16:32]
    • Step 4. 암호화 헤더 구성 및 삽입(116 bytes) 하여 파일 헤더에 암호화 및 복호화에 필요한 메타데이터를 116bytes 고정 크기로 배치한다.
    • Step 5. 실제 파일 데이터 암호화(AES-128-CTR)를 IOCP 기반 워커 스데르가 파일 I/O를 비동기로 처리하여 암호화를 수행하고 CTR 키스트림 생성 루틴에서는 AES-128 블록 암호 구현 기반으로 키스트림을 생성한다. (평문 XOR 키스트림으로 암호문 생성)
      • Counter의 경우 IV를 Little-endian 방식으로 1byte 씩 증가하여 사용한다.
    • Step 6. 최종 결과 처리를 암호화 완료 후에 파일 무결성 및 시그니처 검사(SINOBI 표식 확인)하고 리소스 정리 및 핸들을 종료한다.

6.2 최종 평가 및 결론

  • SINOBI 랜섬웨어는 파일 I/O 성능 극대화를 위해 IOCP(I/O Completion Port) 기반의 비동기 처리 모델을 채택하고 있고, AES-128-CTR 알고리즘을 사용하여 고속 암호화를 수행하는 것으로 확인되었다.해당 랜섬웨어는 운영체제 수준의 비동기 I/O를 활용한 고도화된 기술로 설계돼 있지만 CryptoGenRandom의 초기 키 를 Hooking하여 알 수만 있다면 위와 같은 키 교환 방식을 구현하여 생성된 AES Key와 Nonce(Counter)를 통해 원본 파일이 정상적으로 복구됨을 검증하였다.
  • 파일 복구의 경우 부록 7.2를 참조
  • 키 교환 방식은 Curve25519 기반의 ECDH(Elliptic Curve Diffie-Hellman)를 통해 공격자와 피해자 간의 공유 비밀(Shared Secret)을 생성하며, 이를 SHA-512 KDF로 확장하여 대칭키와 IV를 유도하는 표준적인 하이브리드 암호화 방식을 준수하고 있다.

7. 부록

7.1 KDF를 통해 생성되는 키 검증 동적 분석을 통한 검증

  • 키(SHA256 중 첫번째 16바이트): 3CCA51932770C63635EC4AF28FEE08DA
  • IV(SHA256 중 두번째 16바이트): EBE2333307178C0E389D52EF9F28DB31
  • AES-128-CTR 모드로 키스트림 생성 시 일치 확인

  •  키 스트림 생성 코드(첫 블럭만)
더보기
#!/usr/bin/env python3
"""
AES-128-CTR keystream generator
- 입력: key, iv (둘 다 16바이트 권장). hex 문자열(예: 3cca...)로 입력하면 그대로 사용.
- 출력: 요청한 길이(bytes)만큼의 키스트림(헥스) 및 첫 16바이트 블록 출력.
Requires: pycryptodome (import Crypto)
"""

from Crypto.Cipher import AES
from Crypto.Util import Counter
import sys

def parse_to_bytes(s: str, expected_len: int = 16) -> bytes:
    s = s.strip()
    # 먼저 hex로 시도
    try:
        b = bytes.fromhex(s)
    except ValueError:
        # hex가 아니면 utf-8 바이트로 변환하고 필요하면 패딩/자르기
        b = s.encode('utf-8')
    # normalize length: 자르거나 0으로 패딩
    if len(b) < expected_len:
        b = b + b'\x00' * (expected_len - len(b))
    elif len(b) > expected_len:
        b = b[:expected_len]
    return b

def generate_keystream_aes128_ctr(key: bytes, iv_block: bytes, length: int) -> bytes:
    """
    key: 16 bytes
    iv_block: full 16-byte counter block initial value (big-endian)
    length: number of keystream bytes to produce
    """
    if len(key) != 16 or len(iv_block) != 16:
        raise ValueError("key and iv_block must be exactly 16 bytes each for AES-128.")

    # Counter: treat iv_block as big-endian integer initial counter
    iv_int = int.from_bytes(iv_block, byteorder='big')
    ctr = Counter.new(128, initial_value=iv_int)  # 128-bit counter
    cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
    # In CTR mode, encrypting zero-bytes produces the keystream
    keystream = cipher.encrypt(b'\x00' * length)
    return keystream

def main():
    print("AES-128-CTR keystream generator")
    key_in = input("Key (hex or text): ").strip()
    iv_in  = input("IV  (hex or text): ").strip()
    length_in = input("출력할 키스트림 길이(bytes): ").strip() or "64"

    try:
        length = int(length_in)
        if length <= 0:
            raise ValueError
    except ValueError:
        print("잘못된 길이입니다. 양의 정수를 입력하세요.")
        sys.exit(1)

    key = parse_to_bytes(key_in, expected_len=16)
    iv  = parse_to_bytes(iv_in, expected_len=16)

    print(f"\nUsing key (hex): {key.hex()}")
    print(f"Using iv  (hex): {iv.hex()}")
    ks = generate_keystream_aes128_ctr(key, iv, length)

    print(f"\nKeystream ({length} bytes):\n{ks.hex()}")
    if len(ks) >= 16:
        print(f"\nFirst 16-byte keystream block:\n{ks[:16].hex()}")

if __name__ == "__main__":
    main()

7.2 파일 복호화 코드

  • CryptoGenRandom을 통해 생성된 키 확보 필요(sinobi.exe_11376.txt)
  • [ 요구사항 ]
    • Python 3.8+
    • 설치 패키지: pip install cryptography pycryptodome pandas
  • [사용 명령어 구조]
    • python [복호화].py sinobi.exe_11376.txt
  • 최종 복호화 코드
더보기
#!/usr/bin/env python3

import re, argparse, math, base64, hashlib
from pathlib import Path
from hashlib import sha512, sha256, md5, sha1
from Crypto.Cipher import AES
from Crypto.Util import Counter
from cryptography.hazmat.primitives.asymmetric import x25519
import chardet
import pandas as pd

DEFAULT_ATTACKER_PUB_B64 = "Il2uG3L5S9oCrKxvozNzWSynv8ZWxN09+9w8aLpAhBE="
HEX_RE = re.compile(r"Data:\s*([0-9A-Fa-f]+)")

SIGNATURES = {
    "PNG": b"\x89PNG\r\n\x1a\n",
    "JPEG": b"\xff\xd8\xff",
    "ZIP": b"PK\x03\x04",
    "OLE": b"\xd0\xcf\x11\xe0",
    "PDF": b"%PDF-",
    "XML": b"<?xml"
}

KIND_TO_EXT = {
    "PNG": ".png",
    "JPEG": ".jpg",
    "ZIP": ".zip",
    "OLE": ".doc",
    "PDF": ".pdf",
    "XML": ".xml",
    "XML/TEXT": ".xml",
    "TEXT_ASCII": ".txt",
}

def parse_log_file(path: Path):
    txt = path.read_text(encoding="utf-8", errors="ignore")
    matches = HEX_RE.findall(txt)
    matches = [m.rstrip(".") for m in matches if m]
    return matches

def make_candidates_simple(hexstr):
    cand = set()
    L = len(hexstr)
    if L >= 64:
        cand.add(hexstr[:64])
        cand.add(hexstr[-64:])
        mid_start = max(0, (L//2) - 32)
        if mid_start + 64 <= L:
            cand.add(hexstr[mid_start:mid_start+64])
    return cand

def make_candidates_slide(hexstr, step=2):
    cand = set()
    L = len(hexstr)
    if L < 64:
        return cand
    for i in range(0, L - 64 + 1, step):
        cand.add(hexstr[i:i+64])
    return cand

def clamp_curve25519_priv(b: bytes) -> bytes:
    if len(b) != 32:
        return b
    lb = bytearray(b)
    lb[0] &= 0xF8
    lb[31] &= 0x7F
    lb[31] |= 0x40
    return bytes(lb)

def derive_candidates(shared: bytes):
    out = []
    h512 = sha512(shared).digest()
    out.append(("sha512", h512[:16], h512[16:32]))
    h256 = sha256(shared).digest()
    out.append(("sha256", h256[:16], h256[16:32]))
    out.append(("raw", shared[:16], shared[16:32]))
    return out

def iv_variants(iv_base: bytes, filename: str, filesize: int, attacker_pub: bytes):
    vars = [("base", iv_base), ("reversed", iv_base[::-1])]
    fn = filename.encode("utf-8", errors="ignore")
    vars.append(("xor_md5", bytes(a ^ b for a, b in zip(iv_base, md5(fn).digest()[:16]))))
    vars.append(("xor_sha1", bytes(a ^ b for a, b in zip(iv_base, (sha1(fn).digest() + b'\x00'*16)[:16]))))
    vars.append(("xor_sha256", bytes(a ^ b for a, b in zip(iv_base, sha256(fn).digest()[:16]))))
    fsb = (filesize.to_bytes(8, "big") * 2)[:16]
    vars.append(("xor_filesize", bytes(a ^ b for a, b in zip(iv_base, fsb))))
    if len(attacker_pub) >= 32:
        vars.append(("attacker_pub0", attacker_pub[:16]))
        vars.append(("attacker_pub1", attacker_pub[16:32]))
    vars.append(("name_padded", (fn + b'\x00'*16)[:16]))
    seen, uniq = set(), []
    for label, v in vars:
        if v in seen: continue
        seen.add(v)
        uniq.append((label, v))
    return uniq

def aes_ctr_decrypt(ct: bytes, key: bytes, iv: bytes, little_endian=False, nonce64=False):
    try:
        if nonce64:
            nonce, counter_part = iv[:8], iv[8:16]
            initial = int.from_bytes(counter_part, "big")
            ctr = Counter.new(64, prefix=nonce, initial_value=initial, little_endian=little_endian)
        else:
            initial = int.from_bytes(iv, "big")
            ctr = Counter.new(128, initial_value=initial, little_endian=little_endian)
        return AES.new(key, AES.MODE_CTR, counter=ctr).decrypt(ct)
    except Exception:
        return None

def contains_korean(text: str) -> bool:
    return any('\uac00' <= c <= '\ud7a3' for c in text)

def plausibility_check(data: bytes):
    if not data or len(data) < 4:
        return False, None
    for name, sig in SIGNATURES.items():
        if data.startswith(sig):
            return True, name
    if data.lstrip().startswith(b"<") or data.lstrip().startswith(b"<?xml"):
        return True, "XML/TEXT"
    sample = data[:1024]
    printable = sum(32 <= b < 127 for b in sample)
    ratio = printable / max(1, len(sample))
    if ratio > 0.75:
        return True, "TEXT_ASCII"
    try:
        det = chardet.detect(data[:4096])
        enc = det.get("encoding"); conf = det.get("confidence", 0.0)
        if enc and conf > 0.6:
            text = data.decode(enc, errors="ignore")
            if contains_korean(text):
                return True, f"TEXT_{enc}"
    except Exception:
        pass
    return False, None

def sanitize_filename(raw: str) -> str:
    return "".join(c if (c.isalnum() or c in "._-()") else "_" for c in raw)[:200]

def make_output_name_by_kind(orig_name: str, kind: str):
    base = orig_name.split(".")[0]
    ext = KIND_TO_EXT.get(kind, ".txt" if kind and kind.startswith("TEXT") else ".bin")
    return sanitize_filename(base + ext)

def run_pipeline(logfile: Path, out_base: Path, mode="simple", slide_step=2,
                 attacker_pub_b64=DEFAULT_ATTACKER_PUB_B64, offsets=(0,116),
                 chunk_size=1_000_000, no_chunk=False, nonce64=False):

    items = parse_log_file(logfile)
    if not items:
        print("No Data: entries found in log."); return
    print(f"Found {len(items)} Data entries")

    cand = set()
    for h in items:
        cand |= make_candidates_simple(h) if mode=="simple" else make_candidates_slide(h, slide_step)
    cand = [c.lower() for c in cand if len(c)==64 and all(ch in "0123456789abcdef" for ch in c)]
    print("Candidates:", len(cand))
    if not cand: return

    out_base.mkdir(parents=True, exist_ok=True)
    (out_base / "keys_candidates.txt").write_text("\n".join(cand))

    attacker_pub = base64.b64decode(attacker_pub_b64)
    files = [p for p in logfile.parent.rglob("*.SINOBI")]
    if not files:
        print("No .SINOBI files found."); return
    print("Found", len(files), "encrypted files")

    seen_bases = set()
    matches = []
    for f in files:
        enc = f.read_bytes()
        print("→", f.name, "size", len(enc))
        for hx in cand:
            priv = clamp_curve25519_priv(bytes.fromhex(hx))
            try:
                priv_obj = x25519.X25519PrivateKey.from_private_bytes(priv)
                shared = priv_obj.exchange(x25519.X25519PublicKey.from_public_bytes(attacker_pub))
            except Exception:
                continue
            for dlabel, key, ivb in derive_candidates(shared):
                for ivl, ivv in iv_variants(ivb, f.name, len(enc), attacker_pub):
                    for offset in offsets:
                        if offset >= len(enc): continue
                        dec = aes_ctr_decrypt(enc[offset:], key, ivv)
                        if not dec: continue
                        data = enc[:offset]+dec
                        ok, kind = plausibility_check(data)
                        if not ok: continue
                        out_dir = out_base / "filtered_decrypts"
                        out_dir.mkdir(parents=True, exist_ok=True)
                        base_name = make_output_name_by_kind(f.name, kind)
                        out_path = out_dir / base_name

                        # ✅ skip duplicates (이미 같은 base 이름이 있으면 건너뜀)
                        if base_name in seen_bases:
                            continue
                        seen_bases.add(base_name)

                        out_path.write_bytes(data)
                        matches.append({
                            "file": f.name, "kind": kind, "key_prefix": hx[:8],
                            "derivation": dlabel, "iv_variant": ivl, "offset": offset, "out": str(out_path)
                        })
                        print("[MATCH]", f.name, "->", out_path.name, "kind:", kind)
    if matches:
        pd.DataFrame(matches).to_csv(out_base / "filtered_summary.csv", index=False)
        print("Summary written.")
    else:
        print("No matches found.")
    print("Done.")

def parse_cli():
    p = argparse.ArgumentParser()
    p.add_argument("logfile", help="path to CryptGenRandom log file")
    p.add_argument("--out", default="./recovered_by_log", help="output dir")
    p.add_argument("--mode", choices=["simple","slide"], default="simple")
    p.add_argument("--slide-step", type=int, default=2)
    p.add_argument("--attacker-pub", default=DEFAULT_ATTACKER_PUB_B64)
    p.add_argument("--offsets", nargs="+", type=int, default=[0,116])
    p.add_argument("--no-chunk", action="store_true")
    p.add_argument("--nonce64", action="store_true")
    return p.parse_args()

if __name__ == "__main__":
    a = parse_cli()
    run_pipeline(Path(a.logfile), Path(a.out), a.mode, a.slide_step, a.attacker_pub, a.offsets,
                 no_chunk=a.no_chunk, nonce64=a.nonce64)