Memory dex dump 방지 연구
2025년 6월 27일
ref #
- dex 파일 구조
- dex 로딩
- https://github.com/hluwa/frida-dexdump/blob/master/frida_dexdump/agent/agent.js
- https://mp.weixin.qq.com/s/n2XHGhshTmvt2FhxyFfoMA
Memory Dex dump #
보안 솔루션에서는 고객 앱의 java단 코드 보호를 위해 패킹을 하게된다.
이 중 1세대 패킹 방식은 모든 dex 파일을 암호화한 후 다른 위치에 저장하고 동적으로 dex를 ART에 로드하는 방식을 사용한다.
이 방식은 apk를 디컴파일해도 코드를 볼 수 없고, AndroidManifest.xml에서 클래스 이름을 알게 된다고 해도 frida로 앱을 spawn 하면서 초기에 후킹을 걸 수 없게 된다.
하지만 이렇게 보호한다고 해도 언젠가는 메모리에 dex 파일이 로드되어야 하기 때문에 런타임에 메모리 덤프를 하게되면 암호화가 되어있다고 하더라도 평문의 dex를 획득할 수 있게된다.
Frida Dexdump 분석 #
frida로 앱을 후킹해서 메모리에 있는 dex 시그니쳐를 찾는 도구이다.
일반 검색 #
if (range.base.readCString(4) != “dex\n” && verify(range.base, range, true)) { 이거 이유가 뭘까? gpt는 아래처럼 말함
Android의 ART 런타임이나 dex2oat, Quickening에 의해 메모리에 DEX가 로드될 경우: 보통 페이지 단위 (0x1000-aligned) 메모리 블록의 시작 주소에 dex\n이 위치함 즉, range.base == DEX base일 확률이 높음
vdex, oat , dalvik-cache의 dex파일, mmap에서 직접파일로딩,
- 읽기권한이 있는 메모리 영역을 매핑영역 단위로 가져옴
- dex 매직헤더 검색해서 헤더 사이즈 위치의 값이 0x70이면 dex로 판단한다.
- 시스템의 dex는 제외한다.
- dex 매직헤더가 아닌 경우에도 헤더사이즈 값이 0x70 인지랑 헤더의 maps 오프셋이 maps의 maps오프셋과 같은지 검사하고 dex로 판단
- dex로 판단되면 dex_real_size만큼 덤프
- dex 헤더부터 maps_end (maps 첫 4byte가 count니까 0xC * count로 계산) 까지 real_size
1function searchDex(deepSearch) {
2 var result = [];
3 // 읽기 권한 있는 영역 전체를 enumerate
4 Process.enumerateRanges('r--').forEach(function (range) {
5 // range 사이에서 dex 매직헤더 검색 d e x \n 0 _ _ \0
6 Memory.scanSync(range.base, range.size, "64 65 78 0a 30 ?? ?? 00").forEach(function (match) {
7 // 앱의 dex가 아닌경우는 걸러낸다.
8 if (range.file && range.file.path && (range.file.path.startsWith("/data/dalvik-cache/") || range.file.path.startsWith("/system/"))) {
9 return;
10 }
11 // 추가검증
12 if (verify(match.address, range, false)) {
13 var dex_size = get_dex_real_size(match.address, range.base, range.base.add(range.size));
14 // 검증에 통과하면 result에 추가한다.
15 result.push({
16 "addr": match.address,
17 "size": dex_size
18 });
19 }
20 });
21 // moreSearch 같은것이다. dex 헤더에서 magic 값과 size를 날렸을때 찾기
22 // 그런데 range.base 에서만 찾고있는데 이유를 알아봐야한다 <<< TODO
23 if (range.base.readCString(4) != "dex\n" && verify(range.base, range, true)) {
24 var real_dex_size = get_dex_real_size(range.base, range.base, range.base.add(range.size));
25 result.push({
26 "addr": range.base,
27 "size": real_dex_size
28 });
29 }
30 // ... deepSearch 로직
31}
verify #
추가 검증 로직. 3번째 인자가 false로 들어오면 헤더 크기만 검사한다.
1function verify_by_maps(dexptr) {
2 var maps_offset = dexptr.add(0x34).readUInt();
3 var maps_ptr = dexptr.add(maps_offset)
4 var maps_size = maps_ptr.readUInt();
5
6 for (var i = 0; i < maps_size; i++) {
7 var item_type = maps_ptr.add(4 + i * 0xC).readU16();
8 // item_type == maps 인 경우
9 if (item_type === 4096) {
10 var map_offset = maps_ptr.add(4 + i * 0xC + 8).readUInt();
11 if (maps_offset === map_offset) {
12 // 헤더의 maps 오프셋과 maps에서의 maps 오프셋이 같다면 true
13 return true;
14 }
15 }
16 }
17 return false;
18}
19
20function verify(dexptr, range, enable_verify_maps) {
21 if (range != null) {
22 // 헤더 크기보다 큰지만 검사한다.
23 var range_end = range.base.add(range.size);
24 if (dexptr.add(0x70) > range_end) {
25 return false;
26 }
27
28 if (enable_verify_maps) {
29 // 사실 maps_address 를 구하면서 경계검사하는 코드가 있는데, 로직상 의미없음
30 // 헤더의 maps와 maps안에서의 maps 오프셋이 같은 주소를 가리키면 true
31 return verify_by_maps(dexptr);
32 } else {
33 // 헤더 사이즈가 0x70으로 세팅되어있는지 확인. 이건 최근까지도 고정값임
34 return dexptr.add(0x3C).readUInt() === 0x70;
35 }
36 }
37 return false;
38}
get_dex_real_size #
원래는 헤더의 0x20에서 dex_size를 구할 수 있는데, 얘는 maps_end - dex_base 로 계산해서 real_size를 구한다.
1function get_dex_real_size(dexptr, range_base, range_end) {
2 var maps_address = get_maps_address(dexptr, range_base, range_end);
3 var maps_end = get_maps_end(maps_address, range_base, range_end);
4 return maps_end.sub(dexptr).toInt32();
5}
deepSearch #
deepSearch가 세팅된 경우 일반 검색은 동일하게 동작하고, 추가 검색을 하는것이다.
- 일반 검색에서 찾은 dex들은 전부 덤프 대상에 포함
- 세 조건 모두 맞아야한다.
- scanSync: 70 00 00 00 패턴으로 헤더 사이즈로 검색 (모든 덱스파일 고정값)
- verify: 헤더의 maps 오프셋과 maps의 maps오프셋이 같은지 확인
- verify_ids_off: 헤더의 각 ids 오프셋과 maps의 각 ids오프셋이 같은지 확인
- dex 의심되는 것을 찾으면 real_size가 맞지 않아도 그냥 현재 매핑영역 끝까지 덤프
1function verify_ids_off(dexptr, dex_size) {
2 var string_ids_off = dexptr.add(0x3C).readUInt();
3 var type_ids_off = dexptr.add(0x44).readUInt();
4 var proto_ids_off = dexptr.add(0x4C).readUInt();
5 var field_ids_off = dexptr.add(0x54).readUInt();
6 var method_ids_off = dexptr.add(0x5C).readUInt();
7 return string_ids_off < dex_size && string_ids_off >= 0x70 && type_ids_off < dex_size && type_ids_off >= 0x70 && proto_ids_off < dex_size && proto_ids_off >= 0x70 && field_ids_off < dex_size && field_ids_off >= 0x70 && method_ids_off < dex_size && method_ids_off >= 0x70;
8}
9
10// 그냥 dex header size 기준으로 70 00 00 00 면 확인하고본다.
11Memory.scanSync(range.base, range.size, "70 00 00 00").forEach(function (match) {
12 // header size 가 0x3c 위치에 있으니 헤더 시작부분으로 조정
13 var dex_base = match.address.sub(0x3C);
14 if (dex_base < range.base) {
15 return;
16 }
17
18 // 어차피 dex인 경우는 이미 일반검색에서 찾았음
19 // 헤더의 maps와 maps의 maps 오프셋이 같은지 확인
20 if (dex_base.readCString(4) != "dex\n" && verify(dex_base, range, true)) {
21 // dex 파일로 의심되는 영역에서 maps가 완전히 들어맞는지 확인
22 var real_dex_size = get_dex_real_size(dex_base, range.base, range.base.add(range.size));
23 if (!verify_ids_off(dex_base, real_dex_size)) {
24 return;
25 }
26 result.push({
27 "addr": dex_base,
28 "size": real_dex_size
29 });
30
31 // real_size랑 안맞더라도 덱스 시작점에서 메모리맵 섹션 전체를 덤프
32 var max_size = range.size - dex_base.sub(range.base).toInt32();
33 if (max_size != real_dex_size) {
34 result.push({
35 "addr": dex_base,
36 "size": max_size
37 });
38 }
39 }
40});
덤프 방지 방법 연구 #
dex 시그니쳐 지우기 #
아주기본적인연구 그런데 이거 지워도 되는거랑 지우면 안되는거랑 런타임에 지워도되는거랑 이런거 있을걸
설치시점, 로딩시점, 런타임 이 세가지 시점에서 지워도되는게 다를것임
maps를 흩뿌려놓기 #
뭔가 이론상 아주 크게 dex를 만들어놓고 이리저리 쪼개놓고 오프셋을 바꿔놓으면 될것같다. maps를 앞으로 땡겨놓으면 real_dex_size를 이상하게 만들 수 있을듯
아니면 헤더를 아래에다 놓으면 frida-dexdump녀석이 헤더가 아래인줄알고 시작지점을 잘못잡을 가능성이 높다. 오프셋을 마이너스로 못하나?