Agile.NET(이전 명칭: CodeVeil / SecureTeam) 메서드 가상화에 대한 범용 디버추얼라이저로, 보호기가 모든 보호된 어셈블리와 함께 제공하는 런타임을 리버스 엔지니어링하여 처음부터 구축되었습니다. 이 도구는 주어진 빌드에 포함된 AgileDotNet.VMRuntime.dll이 무엇이든 읽어와서, 실행 시간에 해당 DLL로부터 opcode 맵과 핸들러 의미론을 파생시키고, 가상화된 각 메서드 본문을 일반적이고 검증 가능한 CIL로 다시 작성합니다. 이 과정에서 하드코딩된 opcode 테이블은 없으며, 빌드별 패치도 없고, 이름 기반 매칭 방식도 사용하지 않습니다.
Agile.NET의 보호기는 빌드별로 opcode-to-handler 매핑과 모든 식별자를 무작위화하며, 단순한 패턴 매칭을 방지하기 위해 의미론적으로 동일한 핸들러 클래스를 중복합니다. 이 프로젝트는 특정 버전에 대해 "opcode 7은 add를 의미한다"와 같은 테이블을 절대 하드코딩하지 않습니다. 대신, 로드 시간에 다음 작업을 수행합니다:
- 핸들러 기본 타입을 구조적으로 찾습니다(모든 핸들러가 오버라이드하는 두 개의 추상 메서드를 가진 타입—하나는
BinaryReader로부터 피연산자를 읽고, 다른 하나는 VM 컨텍스트를 대상으로 실행하는 메서드). 그리고 opcode 디스패치 테이블의 등록 순서를 읽습니다.
- 각 핸들러에 대해, 자체 읽기 메서드 IL을 실제 피연산자 바이트와 대조하여 해석(interpret)하고, 실행 메서드 IL은 VM의 스택/로컬/인수(args)에 대한 기호적 모델 위에서 추상적으로 해석함으로써 분류(classify)합니다. 이름은 빌드별로 무작위화되므로 절대 이름을 사용하지 않습니다.
- 결과로 얻은 명령어별 의미론을 공식적인 제어 흐름 그래프(control-flow graph)로 끌어올리고(lift), 그 위에 SSA/SCCP/데드 코드 제거(dead-code elimination)를 실행한 후, 실제 CIL—로컬 변수, 예외 핸들러, 분기 대상 등 모든 것—을 방출합니다.
동일한 코드는 수정 없이 Agile.NET 빌드 6.6.0.35와 6.6.0.42에 대해, 보호기가 방출할 수 있는 "확장형"(500개 이상의 단일 연산자 핸들러 클래스) 및 "압축형"(수십 개의 그룹 디스패치 핸들러) 레이아웃 모두에서, 그리고 가상화된 메서드가 try/catch/filter/fault/rethrow를 함께 사용하는 테스트 케이스(fixture)에 대해 실행되었습니다. 전체 아키텍처 설명은 AgileDevirtualizer/DESIGN.md를 참조하고, 방법론은 VALIDATION.md를 참조하십시오.
이것은 일반적인 '모든 것을 보호 해제' 도구가 아니라 **메서드 본문 디버튜얼라이저(method-body devirtualizer)**입니다. 이 도구는 Agile.NET의 VM 레이어가 메서드에 적용하는 단 하나의 특정 변환을 역으로 수행합니다. 즉, VM 바이트코드를 일반적인 디컴파일러가 읽을 수 있는 CIL로 되돌리는 것입니다. 이 도구는 다음을 하지 않습니다:
- 라이선스 검사나 애플리케이션별 로직을 해킹하지 않습니다. 메서드가 무엇을 하는지는 알 필요도 없고 신경 쓰지도 않으며, 단지 VM 보호가 적용된 것을 인코딩한 방식만 역으로 되돌리는 방법을 알고 있습니다; - 특정 제품을 대상으로 하지 않습니다. 엔진은 디스크에 있는
AgileDotNet.VMRuntime.dll 파일이 무엇이든 완전히 구동되며, 어떤 특정한 보호된 애플리케이션에 대한 지식도 없습니다; - Agile.NET의 별개적이고 관련 없는 식별자 이름 변경(identifier-renaming) 패스를 되돌리지 않습니다. 디버튜얼라이즈된 메서드는 여전히 보호기가 할당한 난독화된 이름을 가지고 있습니다. 이것은 이 도구가 건드리지 않는 다른 변환입니다;
- Agile.NET 자체의 바이너리 부분을 배포하거나 요구하지 않습니다. 사용자가 자신의 보호된 어셈블리와 이에 수반되는 런타임 DLL을 입력으로 제공합니다. 여기에 독점적인 것은 포함되지 않습니다.
이 도구는 보안 리서치/상호 운용성(interoperability) 도구로 제작 및 배포되었으며, de4dot이나 다른 .NET 보호기용 다양한 dnSpy 언패킹 플러그인과 같은 범주에 속합니다.
Agile.NET은 각 가상화된 메서드의 바이트코드를 _CSVM이라는 매니페스트 리소스에 저장합니다. 모든 보호된 메서드에 대해, 이 리소스는 [명령어 개수][opcode...][피연산자 블롭]의 바이트 스트림과 별도로 인코딩된 로컬 변수 및 예외 처리기 테이블을 포함합니다. 원래의 메서드 본문은 런타임의 디스패처(dispatcher)로 들어가는 단일 호출, 즉 CSVMRuntime.RunMethod("<guid>", args)으로 대체됩니다. 따라서 일반적인 디컴파일러는 오직 이 하나의 불투명한 호출만 보게 되며, 실제 로직은 리소스 안에 존재하며 런타임에 AgileDotNet.VMRuntime.dll 내부의 핸들러 클래스에 의해 해석됩니다.
두 가지의 런타임 생성 방식이 존재하며 균일하게 처리된다. 하나는 500개 이상의 작고 중복된 단일 명령어 핸들러 클래스를 가진 현대적인 방식이고, 다른 하나는 판별자 바이트(discriminator byte)를 통해 관련 명령어군을 내부적으로 디스패치하는 12개 미만의 큰 "그룹" 핸들러를 가진 고전적인 방식이다. 핸들러가 항상 하나의 VM 오피코드(VM-opcode)에 하나의 CIL 명령어와 일대일로 매칭되는 것은 아니다. 많은 핸들러가 전체 융합된 기본 블록(fused basic block)을 인코딩한다 (예: "인수 로드, 호출, 필드 저장, 반환"을 단일 오피코드로). 리프터는 이러한 구조를 중심으로 구축된다.
runtime DLL ──► RuntimeModel ─────────────► opcode → handler map
per-handler read/execute methods
protected .exe ──► _CSVM resource ──► per-method: [instructions][operand blob][locals][EH table]
...
모든 리라이트(rewrite) 단계는 동일한 원칙을 따른다. 후보 본문을 구축하고, 이를 독립적으로 검증한다 (스택 깊이/타입 안전성 검사 후 외부 CLR 검증기 사용). 그리고 검증에 성공했을 경우에만 원본 위에 설치하며, 그렇지 않으면 메서드는 원래의 작동하는 VM 기반 형태로 유지되고 그렇게 보고된다. 절대로 조용히 추측되는 것은 없다.
TestCases/Program.cs
이 리포지토리의 TestCases/Program.cs는 특정 제어 흐름(control-flow) 형태를 테스트하기 위해 작성된 원본 테스트 픽스처 코드이다 (중첩된 try/catch, rethrow, finally, 그리고 이 Agile.NET 버전이 보호 시점에 endfilter를 거부하기 때문에 filter 절 없이):
public static int RethrowWithoutFilter(int mode)
{
LastRethrowTrace = 0;
...
이를 Agile.NET으로 보호하고 보호된 출력을 디컴파일하면, 해당 전체 메서드는 사라지고 VM 디스패치 스텁(stub)으로 대체된다:
public static int RethrowWithoutFilter(int mode)
{
return (int)CSVMRuntime.RunMethod("909aa5c1-5cd2-4983-9e2e-b6e55e5498f8", new object[1] { mode });
...
이 형태로는 원래의 로직을 읽어낼 방법이 없다. 스위치(switch)/try/catch/rethrow/finally 구조, 예외 유형, 심지어 존재하는 분기(branch)의 개수까지 모두 불투명하며(opaque), 오직 _CSVM 리소스에만 보관되어 호출 시점에 VM 런타임에 의해 해석된다.
이 도구를 보호된 어셈블리(dotnet run -- TestCases.exe AgileDotNet.VMRuntime.dll out.exe --ssa-phi)에 대해 실행하면 다음과 같이 재구성됩니다:
public static int RethrowWithoutFilter(int mode)
{
LastRethrowTrace = 0;
...
(ILSpy로 디컴파일된 결과, 복합적인 x = x * 10 + 1은 세 개의 개별 문장으로 나타납니다. 이는 CIL이 실제로 평가하는 방식이기 때문이며, 이 도구는 바이트코드를 통해 증명할 수 없는 더 높은 수준의 표현식 폴딩을 절대로 꾸며내지 않습니다.) 소스 코드의 if (mode == 0) / if (mode == 1) 체인은 여기에서 switch문으로 바뀌었습니다. 이는 VM 바이트코드가 이 빌드를 위해 실제로 인코딩한 구체적인 분기 형태이기 때문이며, 둘 다 의미론적으로 동등한 CIL입니다. 그 결과는 독립적입니다: 출력 어셈블리는 VM 런타임 DLL에 대한 참조를 더 이상 가지지 않으며, 실행 시 발생하는 예외, 추적 값 및 메시지는 원본 소스에서 나오는 것과 정확히 동일합니다. 완벽하게 픽셀 단위로 일치하지는 않습니다. 광범위한 코퍼스의 몇몇 메서드는 여전히 디컴파일러가 볼 수 있는 약간의 복사 노이즈(이는 AgileDevirtualizer/DESIGN.md의
| 플래그 | 효과 |
|---|
| (none) | 최적화된 CIL: 디스패처 루프 제거, 엄격한 EH-SSA 정리. 기본값입니다. |
--ssa-phi | 다중 블록 SSA 파이 낮추기 추가 (동일성 클래스 슬롯 / 타입 지정 엣지 복사); 후보는 더 작거나 깨끗하고 독립적으로 검증된 경우에만 설치됩니다. |
--cast-cleanup | 로컬 스택 출처와 실제 CLR 타입 계층 구조에서 불필요한 것으로 입증된 castclass /변환 명령을 제거합니다 — 추측이 아니며, 해결할 수 없는 계층 구조도 아닙니다. |
--legacy-emission | 비교를 위한 테스트 전용 오라클로 유지되는 간단하고 최적화되지 않은 낮추기입니다. |
sample1
/sample2
은 DESIGN.md 전체에서 참조되는 개발 중 사용된 내부 비공개 테스트 코퍼스(명백한 이유로 비공개로 유지되는 실제 Agile.NET 보호 제3자 소프트웨어)와 공개 리버스 엔지니어링 실습용 크랙미입니다. 이들은 의도적으로 이 저장소의 일부가 아닙니다. TestCases/
그리고 FaultCases/
은 이 프로젝트를 테스트하기 위해 특별히 작성된 원본 픽스처이며, Agile.NET 라이선스 사본으로 로컬 보호되며, 포함된 scripts/*.ps1이 검증하는 대상입니다.
위에 언급된 모든 주장은 단순히 주장되는 것이 아니라 기계적으로 확인됩니다 — 전체 방법론은 VALIDATION.md를 참조하십시오: 세 가지 성공 수준(리프트됨 / 빌더 승인됨 / 검증 완료), 외부 CLR 검증기 패스, 그리고 예외 제어 흐름(leave, 중첩된 finally, rethrow)을 포함한 수십 개의 의미론적 벡터에 걸친 알려진 출처 오라클과의 런타임 비교입니다. 이 프라이빗 실제 세계 코퍼스를 대상으로 실행되는 회귀 테스트 스위트는 이 저장소 외부에 있습니다. 여기에 포함된 스크립트(scripts/validate-source-fixtures.ps1, validate-fault-fixture.ps1, build-fault-fixture.ps1, validate-method-quality-report.ps1)는 대신 공개 TestCases
/
FaultCases
픽스처에 동일한 방법론을 적용합니다.
모든 메타데이터 읽기, IL 조작 및 CIL 방출은 AsmResolver (MIT)를 기반으로 합니다.
MIT.