Validate real Windows 11 RAM and skip absent Intel page-table branches

This commit is contained in:
kwonta3wook
2026-10-27 14:32:24 +09:00
parent b178f7b9de
commit 19bc3c71a2
6 changed files with 279 additions and 10 deletions
+8 -3
View File
@@ -4,12 +4,15 @@ Windows·Linux 프로세스 메모리에서 MCP/JSON-RPC 메시지 후보를 추
수집된 메모리 이미지를 분석하고, 메시지 원문·주소·도구 인자·정책 검사 결과를 남깁니다.
**상태: 초기 구현.** 프레임워크 패키지 버전은 `volatility3==2.28.2`로 고정했습니다.
분석 대상은 Windows x64와 Linux x86-64입니다. Windows의 첫 실증 대상은 Windows 11 x64입니다.
합성 테스트와 함께 공개 Linux RAM 이미지 2개를 시험했습니다. MCP 활동 샘플의 Python 프로세스
분석 대상은 Windows x64와 Linux x86-64입니다. 합성 테스트와 함께 공개 Windows 11 x64
빌드 22000 이미지 1개와 Linux 이미지 2개를 시험했습니다.
Windows에서는 선택한 프로세스 4개의 VAD 1,561개를 검사했고 MCP 후보는 0개였습니다.
[Windows 실제 RAM 검증](docs/windows-memory-validation.md)에는 실행 범위와 대형 빈 주소 영역
읽기 개선을 기록했습니다. Linux MCP 활동 샘플의 Python 프로세스
2개에서 후보 41개를 추출하고 원문·해시·VMA 범위를 다시 대조했습니다. 이 샘플은 VMware
메타데이터가 없어 물리 메모리 배치를 추정해 변환한 조건부 결과입니다.
[실제 RAM 검증 기록과 재현 절차](docs/real-memory-validation.md)를 참고하세요.
전체 메시지 복원율·정밀도, Windows 실제 이미지, 다른 커널 빌드의 호환성은 아직 검증하지 않았습니다.
전체 메시지 복원율·정밀도, Windows의 실제 MCP 메시지 추출, 다른 OS 빌드의 호환성은 아직 검증하지 않았습니다.
| 분석할 이미지 | 명령 | 프로세스 메모리 영역 |
| --- | --- | --- |
@@ -25,6 +28,8 @@ Windows·Linux 프로세스 메모리에서 MCP/JSON-RPC 메시지 후보를 추
- Linux는 프로세스 대표 태스크만 열거하고 Volatility의 `get_vma_iter()`로 메모리 영역 접근.
사용자 주소 공간이 없는 태스크와 프로세스 레이어를 만들지 못한 태스크는 건너뛴 이유를 기록.
- 문자열 안의 중괄호, 이스케이프, 중첩 객체, 청크 경계 처리. 누락된 페이지와 VAD/VMA 경계는 연결하지 않음.
- Intel 페이지 테이블에서 부재가 확인된 큰 영역은 경계까지 건너뛰고 누락 바이트를 집계.
물리 메모리 읽기 실패는 페이지 단위로 재시도해 일부 남아 있는 데이터를 보존.
- MCP 메서드, `tools/call` 도구명과 인자, 응답의 `result`/`error` 보존.
- PID·VAD/VMA 시작 주소·프로세스 가상 주소·인코딩·원문 SHA-256 표시.
- 동일 PID와 **타입까지 같은 요청 ID**를 기준으로 요청·응답 후보 제시. ID 충돌은 모호함으로 표시.
+5 -2
View File
@@ -2,7 +2,10 @@
검증일: 2026-10-27. 분석 코드: `62b0f655c35044869400577cdaf53b5a3472a4c7` (v0.2.0).
분석 호스트: Ubuntu 24.04.3, Python 3.12.14, `volatility3==2.28.2`.
플러그인 코드는 수정하지 않고 실행했습니다. 메모리 내용은 실행하지 않았습니다.
아래 최초 실행에서는 플러그인 코드를 수정하지 않았습니다. 이후
[Windows 실제 RAM 검사](windows-memory-validation.md)에서 큰 빈 주소 영역 읽기를 개선한 뒤
두 Linux 이미지도 다시 실행했으며, 모든 JSONL 파일이 최초 결과와 바이트 단위로 같았습니다.
메모리 내용은 실행하지 않았습니다.
**결과:** 공개 MCP 활동 샘플의 Python 프로세스 2개에서 완전한 JSON-RPC 형태의 MCP 후보
41개를 추출했습니다. 41개 모두 해당 PID의 가상 주소에서 다시 읽은 바이트, SHA-256,
@@ -132,6 +135,6 @@ python scripts/verify_linux_export.py --image evidence/sample-derived.lime --sym
실제 송수신 메시지 정답 집합과 수집 지연·작업량 조건을 대조하지 않았으므로 정밀도, 재현율,
오연결률, 인자 부분 복원률을 주장할 수 없습니다. JSON은 로그·캐시·예제에서 왔을 수도 있습니다.
Windows 실제 RAM, 기존 mmap 기반 Linux 커널, 다른 배포판·빌드, UTF-16 실제 사례,
Windows의 실제 MCP 메시지 추출, 기존 mmap 기반 Linux 커널, 다른 배포판·빌드, UTF-16 실제 사례,
부분 조각 추출과 사용자 정책의 실제 사건 판정도 이번 실행 범위 밖입니다.
이 결과는 선택한 프로세스에서의 추출 경로 확인이며, 운영 환경 전반의 검증 완료 선언이 아닙니다.
+3 -1
View File
@@ -3,7 +3,8 @@
이 문서는 자동 테스트의 범위와 실제 이미지 검증 절차를 설명합니다.
공개 Linux 이미지 2개에 대한 실행 결과는 [실제 RAM 검증 기록](real-memory-validation.md)에
별도로 기록했습니다. MCP 활동 샘플은 누락된 VMware 메타데이터를 대신해 추정한 물리 메모리
배치를 적용했습니다. Windows 실제 이미지 검증과 전체 메시지 기준 성능 평가는 아직 수행하지 않았습니다.
배치를 적용했습니다. [Windows 11 실제 이미지 검증](windows-memory-validation.md)도 수행했으나,
그 이미지에서는 MCP 후보가 나오지 않았습니다. 전체 메시지 기준 성능 평가는 아직 수행하지 않았습니다.
## 자동 검증
@@ -18,6 +19,7 @@ CI는 Ubuntu/Windows 분석 호스트와 Python 3.10/3.12 조합을 구성합니
| 파싱 | 문자열 내부 중괄호·이스케이프·중첩, 뒤에 배치된 jsonrpc 멤버, 모든 청크 분할 위치 |
| 손상 | 중복 키, 잘못된 인코딩, 비정상 JSON-RPC ID, 비유한 숫자 거부; 조각에서 필드 추정 금지 |
| 메모리 경계 | 실제 BufferDataLayer 위의 누락 페이지 시뮬레이션, 인접 VAD/VMA 비결합, Linux vm_end 배제 |
| Intel 페이지 변환 | 실제 Intel32e와 구성한 페이지 테이블로 2 TiB 누락 영역 건너뛰기, 예산 경계, 일부만 수집된 큰 페이지 보존 |
| 후보 연결 | PID와 숫자/문자열 ID 분리, 복사본 보존, ID 충돌, 부동소수점 반올림으로 인한 오병합 방지 |
| 정책 | Windows/POSIX 경로 경계·대소문자·구분자, URL 사용자 정보·가짜 접미사, JSON Pointer, 미지원 입력·잘못된 규칙 |
| 어댑터 | 실제 TreeGrid, 프로세스별 제한·요약, 뒤쪽 VAD/VMA 읽기 오류 이후 기존 증거 보존 |
+117
View File
@@ -0,0 +1,117 @@
# Windows 11 실제 RAM 검증
검증일: 2026-10-27. 분석 호스트는 Ubuntu 24.04.3, Python 3.12.14이며
`volatility3==2.28.2`를 사용했습니다. 분석 대상 덤프가 Windows이며 분석 호스트는 Linux입니다.
**결과:** 실제 Windows 11 x64 빌드 22000 이미지에서 사용자 프로세스 4개의 VAD 1,561개를
검사했습니다. 읽은 바이트는 361,902,080 (약 345.14 MiB), MCP 후보는 0개입니다.
플러그인 실행·VAD 접근·누락 집계·JSONL 출력을 검증한 결과이며,
**Windows에서 실제 MCP 메시지를 추출했다는 결과는 아닙니다.**
## 원본과 심볼
[PassMark의 Volatility Workbench 공식 배포 페이지](https://www.osforensics.com/tools/volatility-workbench.html)에
연결된 [Windows 11 덤프](https://www.osforensics.com/downloads/Win11Dump.zip)를 사용했습니다.
ZIP 멤버를 끝까지 읽어 CRC를 확인했고, 추출한 원본에 메시지나 바이트를 주입하지 않았습니다.
메모리에 포함된 프로그램은 실행하지 않았습니다.
| 파일 | 크기 (bytes) | SHA-256 |
| --- | ---: | --- |
| `Win11Dump.zip` | 1,318,367,225 | `150b62a071debc39651368c89ff2ba990fe825a8363be78af0e7889ce9605412` |
| `Win11Dump.mem` | 3,745,951,744 | `0f00815b8f78f30a63d742c73152474b2a3c35183dbb1d92c8fc6a81fa3de9f1` |
| 생성된 NT ISF JSON.XZ | 629,352 | `811a6a7dacfac7d447e1d9213d28df7273576fa821b7eb522ae312e9703fc5df` |
처음 `windows.info.Info` 실행 시 Volatility의 자동 심볼 다운로드·변환을 사용했고,
이후 검사는 `--offline`으로 실행했습니다. 생성된 ISF의 식별자는
`windows/ntkrnlmp.pdb/8E3373D6124E747F0E72EF8E02E676B3-1.json.xz`입니다.
다른 환경에서 ISF를 다시 생성하면 압축 결과의 해시까지 같다는 보장은 없습니다.
`windows.info.Info` 관측값:
| 항목 | 값 |
| --- | --- |
| Is64Bit / MachineType | True / 34404 (AMD64) |
| Major/Minor | 15.22000 |
| NtMajorVersion / NtMinorVersion | 10 / 0 |
| SystemTime | 2022-07-18 23:26:00 UTC |
| Kernel Base / DTB | `0xf80029800000` / `0x1ae000` |
| 레이어 | WindowsIntel32e → FileLayer |
`windows.pslist.PsList`는 103행을 반환했습니다. 그중 PID `128017663995251`,
이름 `Milliseconds,MS`인 비정상적으로 보이는 행도 포함되어 있어 **103개를 모두 실제 정상
프로세스로 확정하지 않았습니다.** 원인은 이번 검사에서 규명하지 않았습니다.
아래 선택한 네 PID는 종료 시각이 없고, `windows.vadinfo.VadInfo`에서도 VAD를 열거했습니다.
## 발견한 읽기 문제와 수정
Explorer·Teams·Edge의 VAD 목록에서 각각 2 TiB 크기의 가상 주소 예약 영역을 확인했습니다.
이 영역은 실제로 2 TiB의 RAM이 수집됐다는 뜻이 아닙니다. 기존 읽기 방식은 청크 읽기 실패 후
빠진 페이지마다 4 KiB 재시도를 하므로 이런 큰 빈 영역을 처리하기에 부적합했습니다.
수정 전의 전체 2 TiB 영역 검사 완료 시간은 측정하지 않았습니다.
`mcptrace/memory.py`는 이제 Intel 프로세스 레이어의 `PagedInvalidAddressException`에 담긴
부재 영역 크기를 확인하고 그 경계까지 건너뜁니다. 호출자의 VAD/VMA 끝과 바이트 예산을 넘지
않으며 건너뛴 길이는 `unreadable_bytes`에 집계합니다. 물리 레이어 읽기 실패에는 이 추정을
적용하지 않고 4 KiB 재시도를 유지합니다. 따라서 큰 페이지의 일부만 수집된 경우에도 읽을 수
있는 부분을 버리지 않습니다. `pad=False`와 누락 구간 비결합도 유지합니다.
분석 기준: 커밋 `b178f7b9de11ab829d08694b445e5ba8e00d91f3`에 이 읽기 수정을 적용했습니다.
실제 실행한 `mcptrace/memory.py` SHA-256:
`12ff75dc67c8a8d6194e98f086fc2edb4987b8bbb248cadb1f4f2771231754ca`.
## 실제 실행 결과
| PID | 프로세스 | VAD | 읽은 bytes | 읽지 못한 bytes | MCP 후보 |
| --- | --- | ---: | ---: | ---: | ---: |
| 3244 | explorer.exe | 808 | 160,899,072 | 2,203,945,033,728 | 0 |
| 6248 | msteams.exe | 221 | 31,633,408 | 2,220,703,334,400 | 0 |
| 6524 | msedge.exe | 337 | 115,310,592 | 2,272,876,556,288 | 0 |
| 2180 | Calculator.exe | 195 | 54,059,008 | 4,567,629,824 | 0 |
각 PID에서 다음을 확인했습니다.
- `regions_scanned`가 native `windows.vadinfo`의 VAD 개수와 일치.
- `bytes_read + unreadable_bytes`가 해당 VAD들의 가상 주소 범위 길이 합계와 일치.
- `error=null`, `limits_hit=[]`, `skipped_reason=null`이며 앵커/후보 제한 도달과 잘못된 영역은 0.
- JSONL 4개에 올바른 PID, `target_os=windows`, `region_kind=vad` 메타데이터와 요약이 존재.
아티팩트가 0개이므로 원문·주소·해시 대조를 통과한 Windows 메시지가 있다고 표현하지 않음.
`unreadable_bytes`에는 사용하지 않는 예약 주소 공간도 포함됩니다. 수 TiB의 덤프 데이터가
손실됐다는 의미가 아니며, swap·비상주 페이지·수집 누락 등의 원인을 모두 구분한 값도 아닙니다.
이 이미지는 2022년 시각을 담은 일반 샘플이며 MCP 송수신 정답 자료가 없습니다.
기본 `max-object-bytes=65536`, `max-records=10000`, `max-bytes=0`으로 실행했습니다.
정책·partial·RPC 전체 표시는 끄고 JSONL 내보내기를 켰습니다. 프로세스별 바이트 예산 제한 없이
선택한 VAD 목록 끝까지 검사했지만, 전체 103행의 주소 공간을 검사한 것은 아닙니다.
CLI 경과 시간 7.01초는 심볼과 파일 캐시가 있는 공유 환경의 한 번의 관측값이며,
다운로드·해시·심볼 준비 시간은 제외합니다. 이미지 전체 처리량 벤치마크가 아닙니다.
## 회귀 확인
실제 Volatility `Intel32e`와 구성한 페이지 테이블을 이용하는 테스트 5개를 추가했습니다.
2 TiB 간격을 페이지마다 읽지 않는지, 인접 가상 페이지의 물리 위치가 떨어져 있어도 읽는지,
누락 페이지 양쪽의 JSON을 결합하지 않는지, 예산 경계와 일부만 수집된 큰 페이지를 확인합니다.
전체 테스트 119개와 Ruff 검사·포맷 검사가 통과했습니다.
[Linux 공개 이미지 2개](real-memory-validation.md)도 같은 수정으로 다시 실행했습니다.
13Cubed PID 4개와 MCPRecon PID 2개의 **모든 JSONL 파일이 수정 전 결과와 바이트 단위로
동일**했습니다. MCP 후보 41개와 원문·주소·해시·연결 표시·스캔 통계가 유지됐습니다.
## 재현 명령
저장소 설치 후 루트에서 Bash로 실행합니다. `evidence/Win11Dump.mem`은 위 원본입니다.
첫 `windows.info`는 심볼을 내려받을 수 있으므로 네트워크를 허용한 상태에서 실행하고,
이후에는 같은 심볼 디렉터리와 캐시를 사용합니다.
```bash
export PATH="$PWD/.venv/bin:$PATH"
mkdir -p cache symbols output/win11
vol -q --cache-path cache -s symbols -f evidence/Win11Dump.mem -r json windows.info.Info > output/win11/info.json
vol -q --offline --cache-path cache -s symbols -f evidence/Win11Dump.mem -r json windows.pslist.PsList > output/win11/pslist.json
vol -q --offline --cache-path cache -s symbols -f evidence/Win11Dump.mem -r json windows.vadinfo.VadInfo --pid 3244 6524 6248 2180 > output/win11/vads.json
vol -q --offline --cache-path cache -s symbols -p plugins -f evidence/Win11Dump.mem -o output/win11 -r json windows.mcptrace.MCPTrace --pid 3244 6524 6248 2180 --dump > output/win11/findings.json 2> output/win11/stderr.txt
```
후속 검증에는 실제 MCP 호출과 정답 메시지를 기록한 Windows 덤프가 필요합니다.
이번 결과만으로 Windows의 MCP 복원 성능, 다른 Windows 빌드 호환성, 정밀도·재현율을
주장하지 않습니다. 원본 덤프·심볼·메모리 내용은 저장소에 커밋하지 않습니다.
+24 -4
View File
@@ -2,6 +2,9 @@
from collections.abc import Iterator
from volatility3.framework import exceptions
from volatility3.framework.layers.intel import Intel
from mcptrace.core import ScanStats
@@ -19,6 +22,7 @@ def readable_chunks(
offset = start
while offset < end:
amount = min(chunk_bytes, end - offset)
next_offset = offset + amount
try:
data = layer.read(offset, amount, pad=False)
if len(data) != amount:
@@ -30,24 +34,40 @@ def readable_chunks(
run = bytearray()
while page < offset + amount:
page_amount = min(4096 - page % 4096, offset + amount - page)
advance = page_amount
try:
part = layer.read(page, page_amount, pad=False)
if len(part) != page_amount:
raise invalid_address(layer.name, page, "Short read")
except invalid_address:
except invalid_address as exc:
if run:
yield run_start, bytes(run)
run.clear()
stats.unreadable_bytes += page_amount
# Intel faults identify the size of an absent page-table
# branch. Skip that branch, possibly beyond this read chunk,
# but never beyond the caller's VAD/VMA range or byte budget.
# Do not apply this to physical backing failures: part of a
# large mapped page can still be readable in a truncated dump.
if (
isinstance(layer, Intel)
and isinstance(exc, exceptions.PagedInvalidAddressException)
and exc.layer_name == layer.name
and page <= exc.invalid_address < page + page_amount
and 12 <= exc.invalid_bits <= 48
):
block_size = 1 << exc.invalid_bits
advance = min(end - page, block_size - page % block_size)
stats.unreadable_bytes += advance
else:
if not run:
run_start = page
run.extend(part)
stats.bytes_read += len(part)
page += page_amount
page += advance
if run:
yield run_start, bytes(run)
next_offset = max(next_offset, page)
else:
stats.bytes_read += len(data)
yield offset, data
offset += amount
offset = next_offset
+122
View File
@@ -0,0 +1,122 @@
"""Exercise sparse scanning with real x86-64 page tables and Volatility layers."""
import struct
from volatility3.framework import contexts, exceptions
from volatility3.framework.layers.intel import Intel32e
from volatility3.framework.layers.physical import BufferDataLayer
from mcptrace.core import ScanStats, scan_chunks
from mcptrace.memory import readable_chunks
PAGE = 4096
HIGH = 1 << 41
def intel_layer(virtual_pages=None):
# Virtual pages 0 and 1 are neighbors but physical pages 5 and 7 are not.
# Page 2 is absent; page 3 maps to physical page 6. Repeat at 2 TiB to
# exercise absent PML4/PDPT/PD branches without allocating a large buffer.
data = bytearray(8 * PAGE)
for address, value in [
(0x1000, 0x2003),
(0x1020, 0x2003),
(0x2000, 0x3003),
(0x3000, 0x4003),
(0x4000, 0x5003),
(0x4008, 0x7003),
(0x4018, 0x6003),
]:
struct.pack_into("<Q", data, address, value)
for virtual_page, physical_page in [(0, 5), (1, 7), (3, 6)]:
page = (virtual_pages or {}).get(virtual_page, bytes([65 + virtual_page]) * PAGE)
assert len(page) == PAGE
data[physical_page * PAGE : (physical_page + 1) * PAGE] = page
context = contexts.Context()
context.layers.add_layer(BufferDataLayer(context, "physical", "physical", bytes(data)))
context.config["virtual.memory_layer"] = "physical"
context.config["virtual.page_map_offset"] = 0x1000
layer = Intel32e(context, "virtual", "virtual")
context.layers.add_layer(layer)
return layer
def test_two_tib_gap_does_not_probe_every_page(monkeypatch):
layer = intel_layer()
original_read = layer.read
calls = []
def read(offset, length, pad=False):
calls.append((offset, length))
assert len(calls) <= 4096, "Missing branches must not trigger per-page reads"
assert not pad
return original_read(offset, length, pad=pad)
monkeypatch.setattr(layer, "read", read)
stats = ScanStats()
size = HIGH + 4 * PAGE
chunks = list(readable_chunks(layer, 0, size, stats, exceptions.InvalidAddressException))
assert [(address, len(data)) for address, data in chunks] == [
(0, 2 * PAGE),
(3 * PAGE, PAGE),
(HIGH, 2 * PAGE),
(HIGH + 3 * PAGE, PAGE),
]
assert chunks[0][1] == b"A" * PAGE + b"B" * PAGE
assert stats.bytes_read == 6 * PAGE
assert stats.unreadable_bytes == size - 6 * PAGE
def test_sparse_reads_preserve_clipped_byte_budget():
layer = intel_layer()
stats = ScanStats()
chunks = list(readable_chunks(layer, PAGE - 3, 12, stats, exceptions.InvalidAddressException))
assert chunks == [(PAGE - 3, b"AAA" + b"B" * 9)]
assert stats.bytes_read == 12 and stats.unreadable_bytes == 0
def test_missing_branch_skip_is_clipped_to_requested_range():
layer = intel_layer()
stats = ScanStats()
chunks = list(
readable_chunks(layer, 2**30 + 7, 9999, stats, exceptions.InvalidAddressException)
)
assert chunks == []
assert stats.bytes_read == 0 and stats.unreadable_bytes == 9999
def test_sparse_reads_do_not_join_json_across_absent_page():
intact = b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
broken = b'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
p0, p1, p3 = bytearray(PAGE), bytearray(PAGE), bytearray(PAGE)
p0[-12:] = intact[:12]
p1[: len(intact) - 12] = intact[12:]
p1[-20:] = broken[:20]
p3[: len(broken) - 20] = broken[20:]
layer = intel_layer({0: p0, 1: p1, 3: p3})
stats = ScanStats()
records = list(
scan_chunks(readable_chunks(layer, 0, 4 * PAGE, stats, exceptions.InvalidAddressException))
)
assert [(r.address, r.request_id) for r in records] == [(PAGE - 12, 1)]
assert stats.bytes_read == 3 * PAGE and stats.unreadable_bytes == PAGE
def test_large_page_with_partial_backing_keeps_readable_bytes():
# A present 2 MiB PDE maps physical address 0, but only 8 physical pages
# were captured. Ignoring errors from mapping the entire range would drop
# its readable prefix; ordinary physical errors must retain page fallback.
data = bytearray(8 * PAGE)
for address, value in [(0x1000, 0x2003), (0x2000, 0x3003), (0x3000, 0x83)]:
struct.pack_into("<Q", data, address, value)
context = contexts.Context()
context.layers.add_layer(BufferDataLayer(context, "physical", "physical", bytes(data)))
context.config["virtual.memory_layer"] = "physical"
context.config["virtual.page_map_offset"] = 0x1000
layer = Intel32e(context, "virtual", "virtual")
context.layers.add_layer(layer)
stats = ScanStats()
chunks = list(readable_chunks(layer, 0, 2**21, stats, exceptions.InvalidAddressException))
assert chunks == [(0, bytes(data))]
assert stats.bytes_read == len(data)
assert stats.unreadable_bytes == 2**21 - len(data)