Merge pull request #3 from Kowntaewook/test/real-linux-ram

실제 Windows·Linux RAM 검증 및 큰 빈 주소 영역 읽기 개선
This commit is contained in:
kwonta3wook
2026-10-27 14:34:49 +09:00
committed by GitHub
9 changed files with 608 additions and 11 deletions
+3
View File
@@ -10,5 +10,8 @@ dist/
*.vmem
*.dmp
*.mem
*.lime
*.vmsn
*.vmss
evidence/
output/
+11 -3
View File
@@ -4,9 +4,15 @@ Windows·Linux 프로세스 메모리에서 MCP/JSON-RPC 메시지 후보를 추
수집된 메모리 이미지를 분석하고, 메시지 원문·주소·도구 인자·정책 검사 결과를 남깁니다.
**상태: 초기 구현.** 프레임워크 패키지 버전은 `volatility3==2.28.2`로 고정했습니다.
분석 대상은 Windows x64와 Linux x86-64입니다. Windows의 첫 실증 대상은 Windows 11 x64입니다.
현재 검증은 합성 데이터, 실제 Volatility 메모리 레이어·출력기, CLI 플러그인 로딩까지입니다.
실제 Windows/Linux RAM 이미지에서의 복원율과 OS 빌드·커널별 호환성은 아직 검증하지 않았습니다.
분석 대상은 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의 실제 MCP 메시지 추출, 다른 OS 빌드의 호환성은 아직 검증하지 않았습니다.
| 분석할 이미지 | 명령 | 프로세스 메모리 영역 |
| --- | --- | --- |
@@ -22,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 충돌은 모호함으로 표시.
+140
View File
@@ -0,0 +1,140 @@
# 공개 Linux RAM 이미지 검증
검증일: 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,
VMA 범위와 일치했습니다. 다만 이 샘플의 VMware 메타데이터가 공개되지 않아, 아래의
**추정한 물리 메모리 배치**를 적용한 조건부 결과입니다. 복원율이나 정밀도 측정은 아닙니다.
## 입력과 전처리
| 입력 | 출처 | 크기 | 확인한 커널 / 역할 |
| --- | --- | ---: | --- |
| 13Cubed `memory.vmem` + `memory.vmsn` | [제작자 설명](https://www.youtube.com/watch?v=IHd85h6T57E), [원본 ZIP](https://cdn.13cubed.com/downloads/linux_challenge.zip) | VMEM 4,294,967,296 bytes; VMSN 5,567,519 bytes | Ubuntu 6.5.0-41-generic, 실제 VMware 메타데이터를 사용한 실행 확인 |
| MCPRecon `sample.vmem` | [저자 저장소 README](https://github.com/BiTLab-BaggiliTruthLab/MCPRecon), [저자가 연결한 샘플](https://drive.google.com/file/d/16d84BfG19auxwERKwajmNnDD0gVYo2Q9/view) | 8,589,934,592 bytes | Ubuntu 6.14.0-37-generic, MCP 후보 추출 확인 |
13Cubed ZIP의 CRC 검사를 통과한 뒤 전체 VMEM을 추출했습니다. 이 이미지는 2024-07-03
프로세스 생성 시각을 포함하며, MCP 통신 정답 자료는 없습니다. 아래에서 선택한 프로세스에서
후보가 나오지 않았다는 사실만 기록하며, 이미지 전체가 MCP 음성이라고 단정하지 않습니다.
MCPRecon 원본 VMEM만 열면 VMware 메타데이터 누락 경고가 발생하고, 기본 `linux.pslist`
결과가 **종료 코드 0이지만 0행**이었습니다. 이를 성공한 검사로 간주하지 않았습니다.
덤프 안에는 6.14.0-36과 6.14.0-37 배너가 모두 있었으며, 아래 배치를 적용한 뒤 6.14.0-37
심볼로 프로세스 목록과 VMA 목록을 읽을 수 있었습니다.
원본에서 확인한 BIOS-e820 텍스트의 주요 범위:
```text
BIOS-e820: [mem 0x0000000000100000-0x00000000bfecffff] usable
BIOS-e820: [mem 0x00000000bff00000-0x00000000bfffffff] usable
BIOS-e820: [mem 0x0000000100000000-0x000000023fffffff] usable
```
이 단서와 총 크기를 근거로 다음 배치를 **추정**했습니다.
| 원본 파일 범위 (끝 제외) | 물리 주소 범위 (끝 제외) |
| --- | --- |
| `[0, 3 GiB)` | `[0, 3 GiB)` |
| `[3 GiB, 8 GiB)` | `[4 GiB, 9 GiB)` |
`scripts/prepare_mcprecon_sample.py`는 정확히 아래 해시의 원본만 허용하며, 모든 원본 바이트를
순서대로 보존하고 두 개의 32바이트 LiME 헤더를 추가합니다. 물리 주소 `[3 GiB, 4 GiB)`는
누락 범위로 남깁니다. 결과물은 **분석용 파생 컨테이너이며 LiME로 수집한 원본이 아닙니다.**
이 추정은 원본 VMSN/VMSS에 의한 확인을 대체하지 않습니다. 다른 VMware 덤프에 일반화하지 마세요.
SHA-256:
```text
13Cubed ZIP ca7f3f4c4638dcbf4b667e19d4406e4a3c5ca323abb42bf0e2a803ef711e4ec2
13Cubed VMEM d718483152a85e82a96c7c260131572556f5aeaf8951f8f73af281fd1840035e
13Cubed VMSN 7953e49f2f2ccef880ad5983ee2e491009f93c0c7837daffa5589aa7560c064f
MCPRecon VMEM 65b5ccea17c6ce82d978d1b258590a42a2723b13f74b88a5335a7b10401c8bbf
MCPRecon 파생 LiME 9c375e111990c55150e6fa42e674e2ef0ddb1e2af1c6c45a4912a58c1d1d6c43
```
심볼은 커뮤니티 [volatility3-symbols](https://github.com/Abyss-W4tcher/volatility3-symbols)의
정확한 배너에 해당하는 ISF를 사용했습니다. 배포판 공식 서명 검증을 했다는 의미는 아닙니다.
| ISF | 다운로드 | SHA-256 |
| --- | --- | --- |
| Ubuntu 6.5.0-41.41~22.04.2 amd64 | [ISF](https://github.com/Abyss-W4tcher/volatility3-symbols/releases/download/isf-Ubuntu-amd64-6.5.0/Ubuntu_6.5.0-41-generic_6.5.0-41.41.22.04.2_amd64.json.xz) | `6b1b998a6804bce921e32756d3ca2545516a8b9a7b944f0cbcc2f9b0f2ccdc44` |
| Ubuntu 6.14.0-37.37~24.04.1 amd64 | [ISF](https://github.com/Abyss-W4tcher/volatility3-symbols/releases/download/isf-Ubuntu-amd64-6.14.0/Ubuntu_6.14.0-37-generic_6.14.0-37.37.24.04.1_amd64.json.xz) | `2230072c4a1565fbf04000a9d0e8c3d01ccb92e410ce0595b353f3f118f23d43` |
## 실행 결과
`linux.pslist.PsList`: 13Cubed 344행, 파생 MCPRecon 349행.
`linux.proc.Maps`: 아래 사용자 PID에 대해 각각 831개와 360개의 VMA를 열거했습니다.
MCPTrace의 `regions_scanned` 합계와 일치합니다. 전체 프로세스를 검사한 것은 아닙니다.
| 이미지 / PID | 프로세스 | VMA | 읽은 bytes | 읽지 못한 bytes | 표시 후보 |
| --- | --- | ---: | ---: | ---: | ---: |
| 13Cubed / 4453 | bash | 31 | 4,894,720 | 6,647,808 | 0 |
| 13Cubed / 4954 | bash | 31 | 5,238,784 | 6,168,576 | 0 |
| 13Cubed / 6742 | sublime_text | 769 | 90,705,920 | 250,605,568 | 0 |
| 파생 MCPRecon / 41879 | python | 191 | 64,262,144 | 163,250,176 | 24 |
| 파생 MCPRecon / 41880 | python | 169 | 59,805,696 | 164,917,248 | 17 |
13Cubed PID 2 (`kthreadd`)는 0바이트를 읽고 `skipped_reason=no-user-address-space`로
건너뛰었습니다. 선택한 모든 PID의 `error`는 null, `limits_hit`는 빈 배열이었고,
앵커/후보 제한 도달 및 잘못된 VMA 카운터는 0이었습니다. 읽지 못한 바이트의 원인을
swap·비상주 페이지·누락·매핑 오류 등으로 추가 판별한 것은 아닙니다.
MCP 후보 41개는 모두 UTF-8 `complete`입니다.
- 요청 27개, 응답 13개, 알림 1개. `tools/call` 9개 모두 도구명이 `get_current_weather`입니다.
- 원문 SHA-256이 서로 다른 바이트열은 33개입니다. 이를 고유 통신 메시지 33개로 간주하지 않습니다.
- 연결 표시는 `candidate-only` 13개, `unpaired` 27개, `not-applicable` 1개입니다.
`candidate-only`는 메시지 **행** 수이며 연결 쌍 수나 올바른 요청·응답 연결률이 아닙니다.
- 별도 검증기는 MCPTrace의 스캐너·어댑터를 사용하지 않고 native Volatility `PsList`와
프로세스 레이어로 다시 읽었습니다. PID 41879의 24개 / 8,879바이트와 PID 41880의
17개 / 2,151바이트, 합계 **41개 / 11,030바이트**가 원문·해시·VMA 범위 대조를 통과했습니다.
주소 변환은 여전히 동일한 Volatility 프레임워크와 위 추정 배치를 사용합니다.
- 검증기의 실패 경로도 확인했습니다. 내보낸 후보의 한 바이트와 그 SHA-256을 함께 바꾼 복사본은
원래 프로세스 메모리와 달라 검증기가 `process memory bytes differ`로 거부했습니다.
검사는 기본 옵션으로 실행했습니다: `max-object-bytes=65536`, `max-records=10000`,
`max-bytes=0`, partial/RPC 전체 표시/정책 검사 끔, JSONL 내보내기 켬.
한 번의 CLI 실행 경과 시간은 13Cubed 11.29초, 파생 MCPRecon 11.68초였습니다.
다운로드·변환·해시·별도 대조 시간은 제외하며 캐시가 있는 공유 실행 환경의 관측값입니다.
8 GiB 전체를 11.68초에 검사했다는 뜻이나 처리 속도 벤치마크가 아닙니다.
## 재현
저장소 설치 후 루트에서 Bash로 실행합니다. `.venv/bin`을 PATH에 추가합니다.
예시 디렉터리의 원본 파일과 위 ISF를 먼저 준비해야 합니다. 심볼은 `symbols/linux/` 안에 둡니다.
원본 12 GiB와 파생 파일 약 8 GiB, 다운로드 압축파일 등을 위한 공간이 필요합니다.
```bash
export PATH="$PWD/.venv/bin:$PATH"
mkdir -p cache symbols/linux output/13cubed output/mcp
# evidence/13cubed/에 memory.vmem과 같은 이름의 memory.vmsn을 함께 둡니다.
vol -q --offline --cache-path cache -s symbols -f evidence/13cubed/memory.vmem -r json linux.pslist.PsList > output/13cubed/pslist.json
vol -q --offline --cache-path cache -s symbols -f evidence/13cubed/memory.vmem -r json linux.proc.Maps --pid 4453 4954 6742 > output/13cubed/maps.json
vol -q --offline --cache-path cache -s symbols -p plugins -f evidence/13cubed/memory.vmem -o output/13cubed -r json linux.mcptrace.MCPTrace --pid 2 4453 4954 6742 --dump > output/13cubed/findings.json 2> output/13cubed/stderr.txt
# 이 해시의 공개 샘플에만 적용되는 추정 배치입니다. 원본은 덮어쓰지 않습니다.
python scripts/prepare_mcprecon_sample.py evidence/sample.vmem evidence/sample-derived.lime > output/mcp/preparation.json
vol -q --offline --cache-path cache -s symbols -f evidence/sample-derived.lime -r json linux.pslist.PsList > output/mcp/pslist.json
vol -q --offline --cache-path cache -s symbols -f evidence/sample-derived.lime -r json linux.proc.Maps --pid 41879 41880 > output/mcp/maps.json
vol -q --offline --cache-path cache -s symbols -p plugins -f evidence/sample-derived.lime -o output/mcp -r json linux.mcptrace.MCPTrace --pid 41879 41880 --dump > output/mcp/findings.json 2> output/mcp/stderr.txt
python scripts/verify_linux_export.py --image evidence/sample-derived.lime --symbols symbols --cache cache --require-artifacts output/mcp/mcptrace.pid-41879.jsonl output/mcp/mcptrace.pid-41880.jsonl > output/mcp/verified.json
```
새 출력 폴더에서 실행하세요. 기존 JSONL이 있으면 Volatility가 이름에 접미사를 붙이므로,
재실행 시 해당 실행의 `scan-summary.export` 파일명을 검증기에 전달해야 합니다.
덤프, 심볼, 원문 메시지가 든 출력 파일은 저장소에 넣지 않습니다.
## 아직 확인하지 못한 것
실제 송수신 메시지 정답 집합과 수집 지연·작업량 조건을 대조하지 않았으므로 정밀도, 재현율,
오연결률, 인자 부분 복원률을 주장할 수 없습니다. JSON은 로그·캐시·예제에서 왔을 수도 있습니다.
Windows의 실제 MCP 메시지 추출, 기존 mmap 기반 Linux 커널, 다른 배포판·빌드, UTF-16 실제 사례,
부분 조각 추출과 사용자 정책의 실제 사건 판정도 이번 실행 범위 밖입니다.
이 결과는 선택한 프로세스에서의 추출 경로 확인이며, 운영 환경 전반의 검증 완료 선언이 아닙니다.
+10 -4
View File
@@ -1,7 +1,10 @@
# 검증 범위
이 문서는 실제 Windows/Linux 호환성 검증을 수행했다는 보고서가 아닙니다.
현재 테스트는 알려진 바이트를 메모리 레이어에 넣어 추출 결과와 비교합니다.
이 문서는 자동 테스트의 범위와 실제 이미지 검증 절차를 설명합니다.
공개 Linux 이미지 2개에 대한 실행 결과는 [실제 RAM 검증 기록](real-memory-validation.md)에
별도로 기록했습니다. MCP 활동 샘플은 누락된 VMware 메타데이터를 대신해 추정한 물리 메모리
배치를 적용했습니다. [Windows 11 실제 이미지 검증](windows-memory-validation.md)도 수행했으나,
그 이미지에서는 MCP 후보가 나오지 않았습니다. 전체 메시지 기준 성능 평가는 아직 수행하지 않았습니다.
## 자동 검증
@@ -16,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 읽기 오류 이후 기존 증거 보존 |
@@ -48,7 +52,7 @@ Windows의 기존 `VADStart`/`vad_start` 출력은 회귀 검사하며 Linux 출
재현 자료에는 명령 전체, MCPTrace 커밋, Python/Volatility 버전, OS 빌드, 정책 파일 및 그 해시,
입력 이미지 해시, 표준 오류 로그, JSONL을 포함합니다. 실제 증거·메모리 덤프는 저장소에 커밋하지 않습니다.
실제 이미지 결과가 확보되기 전에는 특정 클라이언트 지원, 정밀도·재현율, 세션 복원 성공률을 표시하지 않습니다.
기준 메시지와 수집 조건이 확보되기 전에는 특정 클라이언트 지원, 정밀도·재현율, 세션 복원 성공률을 표시하지 않습니다.
## 실제 Linux x86-64 이미지 확인 절차
@@ -68,5 +72,7 @@ Windows의 기존 `VADStart`/`vad_start` 출력은 회귀 검사하며 Linux 출
크기·후보 제한과 검사 오류가 요약에 표시되는지 확인합니다.
기존 mmap 기반 커널과 Maple Tree 기반 커널은 각각 별도의 실제 이미지 검증이 필요합니다.
현재는 Volatility 2.28.2의 `get_vma_iter()` API를 이용하는 구현과 합성 어댑터 테스트까지 수행했습니다.
Volatility 2.28.2의 `get_vma_iter()` API 구현·합성 어댑터 테스트와 함께, 실제 6.5.0-41 및
물리 배치를 추정한 6.14.0-37 이미지에서 Maple Tree 경로를 실행했습니다. 기존 mmap 기반 커널의
실제 이미지는 아직 검증하지 않았습니다.
컨테이너 PID 네임스페이스 매핑, ARM/32비트, swap 복원, 힙 객체 재조립은 이번 검증 범위 밖입니다.
+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
+71
View File
@@ -0,0 +1,71 @@
"""Wrap one specific public MCPRecon VMEM in a derived LiME container.
The source lacks VMware snapshot metadata. Its BIOS-e820 text supports a
3 GiB low RAM / 5 GiB high RAM mapping, with a 1 GiB physical hole. This is an
inferred mapping, not acquisition metadata. The output is NOT a LiME capture.
Only the exact sample identified below is accepted; source bytes are preserved.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
from pathlib import Path
SOURCE_SHA256 = "65b5ccea17c6ce82d978d1b258590a42a2723b13f74b88a5335a7b10401c8bbf"
DERIVED_SHA256 = "9c375e111990c55150e6fa42e674e2ef0ddb1e2af1c6c45a4912a58c1d1d6c43"
GIB = 1024**3
def prepare(source: Path, destination: Path):
if source.stat().st_size != 8 * GIB:
raise ValueError("Expected the specific 8 GiB MCPRecon sample.vmem")
source_hash, output_hash = hashlib.sha256(), hashlib.sha256()
# Exclusive create prevents replacing either the source or an existing result.
with source.open("rb") as src, destination.open("xb") as out:
try:
for start, length in [(0, 3 * GIB), (4 * GIB, 5 * GIB)]:
header = struct.pack("<IIQQQ", 0x4C694D45, 1, start, start + length - 1, 0)
out.write(header)
output_hash.update(header)
remaining = length
while remaining:
block = src.read(min(16 * 1024**2, remaining))
if not block:
raise ValueError("Truncated source")
out.write(block)
source_hash.update(block)
output_hash.update(block)
remaining -= len(block)
if src.read(1) or source_hash.hexdigest() != SOURCE_SHA256:
raise ValueError("Source SHA-256 does not match the documented sample")
if output_hash.hexdigest() != DERIVED_SHA256:
raise ValueError("Derived SHA-256 mismatch")
out.flush()
os.fsync(out.fileno())
except BaseException:
# Only remove the file exclusively created by this invocation.
out.close()
destination.unlink()
raise
return {
"source_sha256": source_hash.hexdigest(),
"derived_sha256": output_hash.hexdigest(),
"derived_bytes": destination.stat().st_size,
"mapping_is_inferred": True,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
print(json.dumps(prepare(args.source, args.destination), indent=2))
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""Re-read Linux JSONL artifacts through native Volatility process layers.
This checks exported bytes, hashes, and VMA bounds. It does not establish that
the JSON was transmitted or measure message recovery against ground truth.
No MCPTrace scanner or adapter is used to perform the verification.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
from pathlib import Path
import volatility3.plugins
import volatility3.symbols
from volatility3 import framework
from volatility3.framework import automagic, constants, contexts, plugins
from volatility3.framework.automagic import stacker
from volatility3.plugins.linux import pslist
def verify_file(path, context, tasks):
with path.open(encoding="utf-8") as stream:
rows = [json.loads(line) for line in stream if line.strip()]
if len(rows) < 2 or rows[0].get("type") != "metadata":
raise ValueError(f"{path.name}: missing export metadata")
header, *records, summary = rows
pid = header["pid"]
if header.get("target_os") != "linux" or header.get("region_kind") != "vma":
raise ValueError(f"{path.name}: not a Linux VMA export")
if header.get("address_space") != "process-virtual":
raise ValueError(f"{path.name}: not a process virtual address export")
if summary.get("type") != "scan-summary" or summary.get("displayed_records") != len(records):
raise ValueError(f"{path.name}: summary/count mismatch")
if pid not in tasks:
raise ValueError(f"{path.name}: PID {pid} is not in native linux.pslist")
task = tasks[pid]
total_bytes = 0
if records:
if not task.mm:
raise ValueError(f"{path.name}: records attributed to a kernel thread")
layer_name = task.add_process_layer()
if not layer_name:
raise ValueError(f"{path.name}: process layer unavailable")
layer = context.layers[layer_name]
regions = {int(v.vm_start): int(v.vm_end) for v in task.mm.get_vma_iter()}
for index, record in enumerate(records, 2):
label = f"{path.name}:{index}"
if (
record.get("type") != "artifact"
or record.get("pid") != pid
or record.get("target_os") != "linux"
or record.get("region_kind") != "vma"
):
raise ValueError(f"{label}: artifact metadata mismatch")
raw = base64.b64decode(record["raw_base64"], validate=True)
if not raw or len(raw) != record["byte_length"]:
raise ValueError(f"{label}: byte length mismatch")
if hashlib.sha256(raw).hexdigest() != record["raw_sha256"]:
raise ValueError(f"{label}: SHA-256 mismatch")
start, address = record["region_start"], record["virtual_address"]
if (
record.get("vma_start") != start
or start not in regions
or not start <= address < address + len(raw) <= regions[start]
):
raise ValueError(f"{label}: VMA bounds mismatch")
if layer.read(address, len(raw), pad=False) != raw:
raise ValueError(f"{label}: process memory bytes differ")
total_bytes += len(raw)
return {"pid": pid, "artifacts_verified": len(records), "bytes_verified": total_bytes}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", type=Path, required=True)
parser.add_argument("--symbols", type=Path, required=True)
parser.add_argument("--cache", type=Path, required=True)
parser.add_argument("--require-artifacts", action="store_true")
parser.add_argument("exports", type=Path, nargs="+")
args = parser.parse_args()
args.cache.mkdir(parents=True, exist_ok=True)
constants.CACHE_PATH = str(args.cache.resolve())
constants.OFFLINE = True
volatility3.symbols.__path__ = [str(args.symbols.resolve()), *constants.SYMBOL_BASEPATHS]
context = contexts.Context()
framework.import_files(volatility3.plugins, True)
# Import automagic modules before selecting stackers: the Linux stackers
# live there, not in framework.layers.
magics = automagic.choose_automagic(automagic.available(context), pslist.PsList)
context.config["automagic.LayerStacker.single_location"] = args.image.resolve().as_uri()
context.config["automagic.LayerStacker.stackers"] = stacker.choose_os_stackers(pslist.PsList)
plugin = plugins.construct_plugin(context, magics, pslist.PsList, "plugins", None, None)
tasks = {
int(task.tgid): task
for task in pslist.PsList.list_tasks(
context, plugin.config["kernel"], include_threads=False
)
}
results = [verify_file(path, context, tasks) for path in args.exports]
count = sum(result["artifacts_verified"] for result in results)
if args.require_artifacts and not count:
raise ValueError("No artifacts to verify")
print(json.dumps({"artifacts_verified": count, "exports": results}, indent=2))
if __name__ == "__main__":
main()
+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)