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 작업 큐를 초기화 및 처리

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








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

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


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

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


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


- 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을 참고



4.1.7 파일 암호화 최종 흐름도

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)
'랜섬웨어 분석 보고서' 카테고리의 다른 글
| Donut 랜섬웨어 분석 보고서 (0) | 2025.12.29 |
|---|---|
| Babuk 랜섬웨어 분석 보고서 (0) | 2025.12.26 |
| DoNex 랜섬웨어 분석 보고서 (1) | 2025.12.20 |
| Beast 랜섬웨어 암호 및 복호화 기술 분석 보고서 (0) | 2025.12.19 |
| MedusaLocker (BabyLockerKZ) 랜섬웨어 분석 보고서 (0) | 2025.12.13 |