
PerimeterX의 바이트코드 VM을 위한 리버스 엔지니어링 툴킷으로, CFG 기반 디스어셈블러, 5계층 복호화 파이프라인, opcode 테이블 재구성, 봇 탐지 핑거프린팅에 대한 보안 연구를 위한 스택 에뮬레이션 클리너를 특징으로 합니다.
이 저장소는 PerimeterX의 auditor.js의 리버스 엔지니어링을 문서화합니다. 이 파일은 PX의 봇 탐지 파이프라인에서 2차 핑거프린팅 레이어로 사용되는 바이트코드 가상 머신입니다. 이 분석은 다음을 포함합니다:
참고: 이 저장소는 (정적) VM 버전 하나만 다루며 보안 연구 및 분석 목적으로 작성되었습니다. 동적 솔버나 프로덕션 솔버 구현을 포함하지 않습니다.
2026년 4월 2일 목요일, PerimeterX는 봇 탐지 파이프라인의 일부로 새로운 바이트코드 VM을 배포했습니다.
auditor.js는 일반적인 PX 센서 스크립트처럼 보이지 않습니다. 일반적인 난독화된 속성 조회 및 수집기 함수 대신:
_fg0 ~ _fg7): 변수에 분할된 암호화된 VM 프로그램_dp): 사이트별 키 (_pk)를 사용하여 프로그램 JSON을 푸는 함수_0x8df7): VM 자체 소스를 해싱하여 복호화 키를 도출하므로, 어떤 수정이든 바이트코드 복호화를 조용히 깨뜨림VM 프로그램은 8개 변수에 분할된 후, _dp()를 통해 사이트별 XOR 암호를 사용하여 복호화되며, 키는 _pk입니다:
var _pk = 893686289;
function _dp(_b) {
var _r = atob(_b), _o = new Array(_r.length);
for (var _i = 0; _i < _r.length; _i++) {
_o[_i] = String.fromCharCode(
_r.charCodeAt(_i) ^ (((_pk >>> (8 * (_i % 4))) ^ Math.imul(_i + 1, 0x6B8B4567)) & 0xFF)
);
}
return _o.join("");
}
결과는 난독화된 두 문자 키 이름(예: 시드의 경우 "uo", 논스의 경우 "dk")을 가진 JSON 객체입니다. 매핑 테이블이 이를 표준 이름으로 변환합니다.
node extractor.js
# -> program.json
모든 1095개 문자열 상수는 두 계층으로 암호화됩니다:
계층 1: 4008000571로 키잉된 정적 murmur XOR, 위치에 따라 다름.
계층 2: PRNG 스트림 XOR (glibc LCG 사용), 프로그램 시드와 각 상수 인덱스를 Knuth의 곱셈 해시로 결합하여 시딩.
복호화 전, 시드는 환경 핑거프린트 (_0xaf48)와 XOR됩니다. 이는 8비트 비트마스크로, 브라우저 API를 프로빙하여 계산됩니다:
Chrome의 경우: _0xaf48 = 0b01111111 = 127, 유효 시드 = 12755 ^ 127 = 12716.
즉, 같은 프로그램이 다른 환경에서 다른 복호화 결과를 생성합니다. Node.js, Chrome, Firefox에서 실행하면 다른 시드가 생성됩니다.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
복호화된 문자열은 VM이 정확히 어떤 것을 핑거프린팅하는지 알려줍니다:
브라우저 핑거프린팅: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
성능 타이밍: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
RSA 암호화: BigInt, modPow, AQAB (base64로 65537), modulusLength, shiftLeft, shiftRight, getRandomValues
DOM/SVG 프로빙: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
PX 필드 이름: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, , ,
엔드포인트 참조: https://fst-ec.perimeterx.net/?id=
안티 디버거: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
전체 JavaScript 언어를 포괄하는 107개의 기본 opcode와 동적으로 생성된 노이즈:
40개의 허니 opcode는 산술/비교 연산의 대체 구현으로, 수학적으로 동일하지만 구문적으로 다른 표현을 사용합니다. ADD는 (a^b) + 2*(a&b) 또는 -((-a)-b) 또는 a-(-b)로 나타날 수 있습니다. 각 기본 opcode는 최대 3개의 변형을 가질 수 있으며, 시드에서 결정론적으로 생성됩니다. 단순한 ADD 명령어가 같은 프로그램 내에서 4개의 다른 바이트코드 값으로 나타나 패턴 매칭 접근을 무력화합니다.
24개의 패딩 opcode는 순열에 할당되지만 핸들러가 없으며 절대 방출되지 않습니다. 이는 opcode 공간을 확장하고 셔플을 역추적하기 어렵게 만드는 역할을 합니다.
16개의 슈퍼명령어 그룹은 가장 중요한 안티 분석 기능입니다. 디스패치 루프가 opcode를 슈퍼명령어 리더로 해석하면, 핸들러는 바이트코드 스트림에서 추가 바이트 하나를 읽고 서브 핸들러로 디스패치합니다. 서브 핸들러는 완전히 다른 연산일 수 있습니다:
Opcode 테이블은 유효 시드로 시딩된 Fisher-Yates를 통해 셔플되므로 바이트코드 값이 빌드마다 다릅니다.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
이 툴킷의 핵심입니다. cfg.js는 PC=0부터 모든 실행 경로를 따라가며 각 명령어를 올바른 암호화 컨텍스트로 디코딩하여 제어 흐름 그래프를 구축합니다.
PX는 블록 경계에서 중첩 명령어를 사용합니다. 동일한 바이트가 한 실행 경로에서는 피연산자로 디코딩되고 다른 경로에서는 opcode로 디코딩되며, 이는 블록 암호화 컨텍스트에 따라 다릅니다. 선형 스캔은 각 바이트 위치를 한 번만 디코딩하여 대체 경로를 놓칩니다. CFG는 폴스루 및 점프 가장자리를 모두 따라 각 경로를 독립적으로 디코딩합니다.
_0x3ca8): 4008000571로 키잉된 원시 base64 바이트에 대한 정적 murmur XOR_0xece1): 함수별 XOR, 두 서브 계층 포함: 위치 의존 정적 키 + 코드 무결성 해시 키_0x427d): 블록별 롤링 XOR. 각 암호화 블록(fn.bl 경계로 정의)은 함수 키와 블록 인덱스에서 파생된 추가 XOR을 얻습니다. 블록 0은 첫 접근 시 암호화되지 않음; 블록 1+는 암호화됨. 이것이 첫 번째 블록에서는 선형 디스어셈블러가 작동하지만 이후 블록에서는 쓰레기를 생성하는 이유입니다.CFG는 다섯 계층을 모두 비파괴적으로 적용하므로 (피연산자 XOR은 제자리가 아닌 즉시 계산), 중첩 명령어 영역이 서로 손상되지 않습니다.
각 슈퍼명령어 리더에 대해 CFG는 서브 바이트를 읽고 super_groups.json에서 실제 핸들러를 조회한 후 실제 opcode의 피연산자를 디코딩합니다. 융합된 점프 opcode(예: ASSIGN_OP_VAR처럼 보이지만 실제로는 JMP인 경우)의 점프 대상도 올바르게 추적됩니다.
node cfg.js # 모든 함수 -> cfg_output/
node cfg.js 79 # 단일 함수 -> stdout
브라우저 실행 추적으로 검증됨: 14개 함수에서 600개 명령어 추적, 스택 델타에서 0 불일치. 34개 고유 opcode 모두 검증됨. 초기화 중 실행된 모든 10개 리더 그룹에 대해 슈퍼명령어 디스패치가 올바르게 확인됨.
CFG 출력을 가져와 스택 에뮬레이션을 실행하여 표현식 주석을 생성합니다. 원시 바이트코드를 읽기 쉬운 의사 코드로 변환합니다.
node cleaner.js 79 # fn79 -> stdout
명령어를 순서대로 탐색하며 가상 스택을 추적합니다. 각 push/pop/call은 표현식 문자열을 구축합니다:
0018 GET_VAR ; 0.0001
001e GET_VAR ; or
0024 PUSH_CONST ; "_0x166"
002b CALL_METHOD_C ; 0.0001._0x88(or, "_0x166")
...
0114 PUSH_CONST ; "fontSize"
011b PUSH_CONST ; "pdc"
0122 CALL_METHOD_C ; _0x18c.getHours("fontSize", "pdc")
빈 스택에서 순수 산술/비교 노이즈만 억제됩니다. CFG가 이미 허니와 패딩을 필터링했으므로 나머지는 모두 유지됩니다.
112개 함수, 5435개 명령어 유지, 207개 노이즈 억제. fn79 (핑거프린트 수집기, 1109개 명령어)는 85% 표현식 주석 커버리지를 가짐.
256슬롯 스택, 스코프 체인, try/catch 핸들러 체인, for-in 반복자 스택을 갖춘 스택 기반 VM. 디스패치 루프는 2바이트 LE opcode를 읽고, 순열 + 위치 XOR + 블록 오프셋을 통해 해석하고, 피연산자를 제자리에서 복호화하고, 핸들러를 실행한 후 피연산자를 다시 암호화하여 바이트코드가 메모리에 완전히 복호화된 상태로 남지 않도록 합니다.
BigInt, modPow, 지수 65537을 사용한 핑거프린트 출력의 RSA 암호화getTotalLength() 및 getBBox()를 통한 SVG 렌더링 핑거프린팅performance.timing 워터폴 수집CC|CD-04|BREAKPOINT-005)AI가 코드 문서화, 도구 작성, 그리고 이 readme 초안 작성에 사용되었습니다.
순전히 교육적/보안 연구 목적입니다. 솔버나 우회 기법은 포함되어 있지 않으며, VM이 어떻게 작동하는지 문서화하는 것이 전부입니다. 이 저장소는 진정으로 흥미롭기 때문입니다.
PerimeterX/HUMAN Security 관계자 중 이 저장소에 대해 우려 사항이 있으면 언제든지 연락 주십시오: [email protected]
| 필드 | 설명 |
|---|
s | 시드 (12755), 모든 암호화 연산을 구동 |
n | 논스 (1603730985), 프로그램별 무작위화 |
g | 생성기 플래그, 무결성 해시 복호화 계층 활성화 |
x | 암호화 플래그, 상수는 XOR 암호화됨 |
c | 상수 풀, 1230개 항목 |
f | 함수, 112개 항목 (암호화된 바이트코드 포함) |
e | 진입점, 함수 인덱스 0 |
| 비트 | 테스트 | Chrome |
|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint 존재 | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports 존재 | 1 |
| 5 | navigator.sendBeacon 존재 | 1 |
| 6 | document.execCommand 존재 | 1 |
| 7 | process.versions.node 존재 (Node.js) | 0 |
adsenveinit| 리더가 해석하는 방식 | 서브 바이트 | 실제 실행 |
|---|
FOR_IN_NEXT | 74 | FOR_IN_NEXT |
FOR_IN_NEXT | 100 | MAKE_CLOSURE |
ASSIGN_OP_VAR | 165 | ASSIGN_OP_VAR |
ASSIGN_OP_VAR | 37 | JMP |
GET_VAR_PROP_C | 143 | SET_VAR_POP |
GET_VAR_PROP_C | 23 | JMP_NULLISH |