Skip to content
블로그로 돌아가기

Windows 10/11 ShimCache `10ts` 함정: 1바이트 차이로 0개 레코드를 반환하는 시그니처 버그

u
unJaena Team
2026년 5월 1일8 분 소요
Windows 10/11 ShimCache `10ts` 함정: 1바이트 차이로 0개 레코드를 반환하는 시그니처 버그

Windows 10/11 ShimCache 10ts 함정: 1바이트 차이로 0개 레코드를 반환하는 시그니처 버그#

TL;DR#

포렌식 파서가 AppCompatCache 레지스트리 값을 3바이트 b"sts" 시그니처로 검색하고 있다면, Windows 10 1607+ 이미지에서는 매번 0개 레코드만 반환됩니다. 실제 매직은 4바이트 b"10ts" (0x31 0x30 0x74 0x73)입니다. 합성 fixture에서는 몇 달간 잡히지 않던 버그였고, 실제 Windows E01 이미지 4개를 Plaso와 비교 테스트하면서 비로소 드러났습니다. 수정은 6줄 코드와 주석 한 줄. 진단은 xxd와 Eric Zimmerman의 AppCompatCacheParser만으로 5분이면 충분했습니다.

이 글에서는 (1) ShimCache가 무엇이고 왜 이 버그가 조용히 실패하는지, (2) 정확히 어떤 바이트 레이아웃 실수였는지, (3) 그 바로 옆에 있던 두 번째 작은 실수(path_size가 BYTES인지 WCHAR ×2인지), (4) 본인 파서를 검증하는 방법, (5) 합성 fixture가 아닌 실제 이미지로 테스트해야 하는 방법론적 이유를 다룹니다.

ShimCache는 무엇이고, 조용한 실패가 왜 위험한가#

ShimCache(공식 명칭은 Application Compatibility Cache, 레지스트리의 SYSTEM\ControlSet00x\Control\Session Manager\AppCompatCache에 저장됨)는 Windows가 남기는 가장 유용한 실행 증거 중 하나입니다.

  • shim 엔진이 검사한 실행 파일의 전체 경로와 $STANDARD_INFORMATION last-modified 타임스탬프를 기록합니다.
  • 바이너리가 삭제된 후에도 엔트리가 남아있는 경우가 많습니다. 침해사고 대응(IR)이나 내부자 위협 조사에서 "이 머신에서 이 프로그램이 실행되었다"는 증거의 1차 신호로 활용됩니다.
  • 재부팅에 걸쳐 유지되며, 종료 시 레지스트리에 flush되고, SYSTEM 하이브 사본만으로 오프라인 분석이 가능합니다.

바이너리 레이아웃의 표준 레퍼런스는 Mandiant 화이트페이퍼 Leveraging the AppCompatCache for Forensic Analysis입니다. 이후 Eric Zimmerman의 AppCompatCacheParser나 Andrew Davis 같은 커뮤니티 작업으로 갱신되어 왔습니다. 포맷은 여러 번 변경되었습니다 — XP/2003, Win7/Server 2008R2, Win8/8.1, Win10 RTM, 그리고 **Windows 10 1607 (Anniversary Update, 2016)**에서 한 번 더 바뀐 뒤로 지금까지 안정화되어 있습니다.

이런 부분에서 파서 버그가 위험한 이유는 조용히 실패하기 때문입니다. 동작 흐름은 다음과 같습니다:

  1. 하이브를 엽니다 ✓
  2. AppCompatCache 값을 찾습니다 ✓
  3. 바이트를 읽습니다 ✓
  4. 엔트리 루프를 돌리고, 일치하는 시그니처를 0개 발견합니다 ✓
  5. []를 반환합니다

다운스트림(타임라인, IR pivot 테이블, MITRE ATT&CK T1518.001 탐지)이 받는 결과는 "이 박스에는 실행 증거가 없다" — 즉 깨끗한 머신과 — 완전히 똑같습니다. 보고서에는 "프로그램 실행 증거 없음"이라고 적혀나갑니다. 예외도, 로그도, 커버리지 갭 경고도 발생하지 않습니다.

8바이트 안에 숨어있던 버그#

Win10 1607+ ShimCache 한 레코드의 엔트리 헤더는 다음과 같습니다 (Mandiant + Zimmerman 기준):

오프셋바이트필드
+0x0031 30 74 73DWORD signature ("10ts" ASCII)
+0x04xx xx xx xxDWORD unknown / padding
+0x08xx xx xx xxDWORD entryDataSize
+0x0Cxx xxWORD pathSize (BYTES, not WCHAR)
+0x0E<pathSize bytes>path (UTF-16LE, no null terminator)
+...xx xx xx xx xx xx xx xxFILETIME lastModified
+...xx xx xx xxDWORD dataSize
+...<dataSize bytes>data (last DWORD == 1 → executed)

시그니처는 b"10ts" — 바이트 0x31 0x30 0x74 0x73입니다.

버그는 이렇습니다. 원래 코드가 3바이트 부분 문자열 b"sts" (바이트 0x73 0x74 0x73)를 검색하고 있었습니다. 그런데 b"sts"b"10ts" 안에 존재하지 않습니다. 10ts에는 s 바이트가 단 하나만 있고, 그것도 끝 자리입니다. 검색이 blob 전체를 훑어도 매칭이 일어나지 않고, 엔트리 루프는 entries = []로 종료됩니다. 끝.

3바이트 sts가 어떻게 Win10 파서에 들어왔을까요? 가장 가능성 높은 시나리오는, 누군가 오래된 레퍼런스에서 매직 바이트의 trailing 3바이트를 Python bytes 리터럴로 축약해 옮겨 적은 경우입니다. 컴파일이 통과하고, 린트도 통과하고, 실제 Windows 10 매직 바이트가 들어있지 않은 fixture에서 단위 테스트도 통과합니다. 진짜 레지스트리 하이브를 들이밀기 전까지는 아무도 이의를 제기하지 않습니다.

수정은 단순합니다:

python
# 4-byte magic per Mandiant / Zimmerman AppCompatCacheParser:
# Win10 1607+ uses ASCII "10ts" (0x31 0x30 0x74 0x73)
sig = data[offset:offset+4]
if sig != b'10ts':
    next_sig = data.find(b'10ts', offset)
    if next_sig == -1:
        break
    offset = next_sig

두 가지 포인트가 있습니다. (a) 비교는 4바이트로 합니다 (3바이트 아님). (b) 현재 오프셋에 시그니처가 없을 때 bytes.find다음 후보로 점프합니다. 1바이트씩 증가시키지 않으므로, 엔트리 뒤에 붙은 패딩을 한 바이트씩 재스캔하지 않고 한 번에 건너뜁니다.

path_size 안에 숨어있던 두 번째 버그#

매직 바이트 수정 직후 두 번째 이슈가 드러났습니다. 오프셋 +0x0Cpath_size 필드는 WORD입니다 — 2바이트, little-endian. 일부 오래된 레퍼런스는 이 값을 "WCHAR 기준 문자 수"로 문서화합니다. 즉, ×2를 곱해서 byte length를 얻으라는 말입니다. 하지만 실제 Win10 1607+ 레이아웃에서는 path_size가 이미 byte length 그 자체입니다. ×2로 곱하면 엔트리 끝을 지나 다음 레코드(또는 버퍼 바깥)로 들어가게 됩니다. UnicodeDecodeError나 오프셋 오버런이 발생하고, 엔트리 루프는 이를 catch한 뒤 skip — 조용히 넘깁니다.

Mandiant 화이트페이퍼는 이 부분에 명확합니다 — 필드는 byte 단위로 문서화되어 있습니다. 다만 두 글이 단위 표기가 서로 다를 때, 잘못된 쪽을 배포하기가 얼마나 쉬운지 한 번이라도 겪어보신 분이라면 아실 겁니다.

python
# WORD path_size, in BYTES — UTF-16LE라고 해서 ×2 하지 마세요.
# Eric Zimmerman의 AppCompatCacheParser source에서 cross-check.
path_len = struct.unpack('<H', data[offset:offset+2])[0]
offset += 2
path = data[offset:offset+path_len].decode('utf-16le', errors='ignore').rstrip('\x00')
offset += path_len

어떻게 잡았나 — 실제 이미지 비교 테스트#

ShimCache 검증은 그동안 합성 fixture(문서화된 레이아웃에 맞춰 손으로 만든 결정론적 blob)로 해왔습니다. 합성 fixture는 통과합니다. 늘 통과합니다 — 그 fixture는 파서에 맞춰 작성된 것이지, 실제 OS에 맞춰 작성된 것이 아니기 때문입니다.

버그는 실제 Windows E01 이미지 4개를 대상으로 Plaso(Google DFIR 팀이 유지보수하는 레퍼런스 파서 suite)와 나란히 우리 파서를 돌렸을 때 드러났습니다. 이미지 크기는 13 GB부터 23 GB까지, 모두 Windows 10 1607+ 또는 Windows 11입니다. 4개 모두 AppCompatCache 값이 채워져 있었습니다. Plaso는 4개 중 3개에서 레코드를 반환했습니다(4번째는 비어있었고, Plaso와 우리 파서 모두 동일하게 0으로 일치했습니다). 그런데 우리 파서는 4개 모두에서 0개 레코드를 반환했고, Plaso가 명백히 데이터를 찾아낸 3개 이미지에서도 똑같이 0이었습니다.

진단은 데이터를 손에 넣은 뒤 5분이면 충분했습니다. 레지스트리 값의 바이트를 덤프해서 매직을 찾아보면 됩니다:

text
# illustrative hexdump of an AppCompatCache value, header 0x34
00000000: 3400 0000 0000 0000 0000 0000 0000 0000  4...............
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 3130 7473 b2c8 7e88 e0e0 0000  ....10ts..~.....

오프셋 0x34에 바이트 31 30 74 73("10ts")가 보입니다. 그게 매직입니다. "sts"가 아닙니다.

매직 바이트와 path_size 수정이 들어간 뒤, 데이터가 채워져 있던 3개 이미지가 깨끗하게 파싱되었습니다. 4번째(정상적으로 비어있던) 이미지는 0개 레코드를 반환 — Plaso와 일치 — Plaso 비교 테스트는 MATCH ("both tools produced zero events — consistent") 등급을 받았습니다. 이는 "we agree with the industry reference"라는 Daubert 방어가 가능한 입장에서 원하는 결과입니다.

이와 함께, Mandiant와 Zimmerman 레이아웃(4-byte 10ts 매직, BYTES path_size, FILETIME, 실행 플래그가 들어간 optional data segment)에 정확히 맞춘 Win10 엔트리를 빌드하는 합성 회귀 테스트도 작성했습니다. 이제 올바른 path와 올바른 executed boolean으로 3/3 엔트리를 생성합니다 — 깨진 시그니처로 회귀하지 않도록 막는 가드 역할입니다.

5분 만에 본인 파서 검증하는 방법#

본인의 ShimCache 코드가 있거나 오래된 OSS 파서를 포크했다면, 아래의 5분 self-check를 권장합니다:

  1. 합법적으로 소유한 Windows 10 1607+ 박스의 SYSTEM 하이브를 가져옵니다 (본인 노트북도 됩니다).
  2. reglookup, python-registry, 또는 regf-toolsAppCompatCache 레지스트리 값 바이트를 추출합니다.
  3. xxd로 덤프합니다. 오프셋 0x30 또는 0x34를 봅니다. 31 30 74 73("10ts")이 보이면 modern 레이아웃을 사용 중인 것입니다.
  4. 같은 하이브에 본인 파서를 돌립니다. 0개 레코드인데 step 3에서 10ts가 보였다면, 시그니처 검색이 깨진 것입니다.
  5. **같은 하이브에 Eric Zimmerman의 AppCompatCacheParser**를 돌립니다. 레코드 수가 몇 개 차이 안에서 일치해야 합니다 (마지막 truncated 엔트리를 처리하는 방식이 파서마다 조금씩 다를 수 있습니다 — 작은 차이는 OK, 자릿수 차이는 NO).

포렌식 도구를 배포한 적이 있는데 step 5를 진짜 레지스트리 하이브에 한 번도 돌려보지 않았다면, 숙제가 한 가지 있는 셈입니다.

방법론적 시사점#

실제 이미지 테스트를 한 라운드 돌리면서 schema-drift 버그를 6개 잡았습니다. ShimCache 10ts가 그중 하나였고, 나머지는 최신 Android와 iOS에 걸쳐 있었습니다. WhatsApp chat → jid join에서 230개 메시지가 조용히 사라지던 버그, Telegram이 messages → messages_v2로 테이블 이름을 바꾼 변경, Contacts 스키마가 raw_contacts로 분리된 변경, Android 11+의 packages.xml이 ABX 포맷으로 바뀐 변경, iOS 17 Safari가 hi.title에서 hv.title로 컬럼을 옮긴 변경. 모두 합성 fixture에서는 통과했습니다. 모두 실제 이미지 테스트를 건너뛰었다면 운영 환경에 조용히 배포되었을 것들입니다.

반복되는 실패 패턴은 한결같습니다. 실제 OS 스키마는 릴리즈 사이에서 조용히 바뀌고, fixture 작성자는 그 변경을 따라잡지 못하고, 파서는 존재하는 유일한 테스트만 계속 "통과"하고, 조용한 실패는 "이 디바이스에 증거가 없다"와 똑같이 보입니다. 포렌식 작업에서 가장 나쁜 종류의 버그 — 높은 확신으로 틀린 답을 내놓는 버그입니다.

교훈은 "합성 fixture를 쓰지 말라"가 아닙니다. 합성 fixture는 우리가 예상하는 레이아웃에 대해 파서를 단위 테스트하기에는 훌륭합니다. 다만 업계 레퍼런스 도구를 기준점으로 두는 실제 이미지 비교 테스트와 짝지어야 합니다. 크로스플랫폼은 Plaso, Windows 레지스트리는 Eric Zimmerman의 도구, iOS/Android는 해당 모바일 레퍼런스 — 각자에 맞춰서. 빌드 파이프라인에 실제 이미지 비교 lane이 없다면, 보조 계기 없이 비행하는 셈입니다.

이 fix는 어디에 있나#

이 수정은 unjaena-collector v2.4.0와 거기에 매칭되는 server-side 파서에 함께 배포되었습니다. collector는 AGPL-3.0 라이선스입니다. 바이너리 레이아웃의 레퍼런스로는 Mandiant Leveraging the AppCompatCache for Forensic Analysis 화이트페이퍼와 Zimmerman의 repo, 이 두 개가 가장 신뢰할 만합니다.

저희가 커버하는 다른 아티팩트에서 비슷한 schema drift를 발견하셨다면 이슈를 열어주세요. 또 하나의 조용한 0을 배포하는 것보다 미리 듣는 편이 훨씬 낫습니다.

공유하기

AI 포렌식 인사이트를 받아보세요

AI 흔적, AI 코딩 흔적, 브라우저·아티팩트 분석 글을 이메일로 보내드립니다.

인사이트 구독하기