Memory dex dump 방지 연구

Memory dex dump 방지 연구

2025년 6월 27일

ref #


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녀석이 헤더가 아래인줄알고 시작지점을 잘못잡을 가능성이 높다. 오프셋을 마이너스로 못하나?


comments powered by Disqus