Memory dex dump 방지 연구

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
function searchDex(deepSearch) {
    var result = [];
    // 읽기 권한 있는 영역 전체를 enumerate
    Process.enumerateRanges('r--').forEach(function (range) {
        // range 사이에서 dex 매직헤더 검색      d  e  x  \n 0  _  _  \0
        Memory.scanSync(range.base, range.size, "64 65 78 0a 30 ?? ?? 00").forEach(function (match) {
            // 앱의 dex가 아닌경우는 걸러낸다.
            if (range.file && range.file.path && (range.file.path.startsWith("/data/dalvik-cache/") || range.file.path.startsWith("/system/"))) {
                return;
            }
            // 추가검증
            if (verify(match.address, range, false)) {
                var dex_size = get_dex_real_size(match.address, range.base, range.base.add(range.size));
                // 검증에 통과하면 result에 추가한다.
                result.push({
                    "addr": match.address,
                    "size": dex_size
                });
            }
        });
        // moreSearch 같은것이다. dex 헤더에서 magic 값과 size를 날렸을때 찾기 
        // 그런데 range.base 에서만 찾고있는데 이유를 알아봐야한다 <<< TODO
        if (range.base.readCString(4) != "dex\n" && verify(range.base, range, true)) {
            var real_dex_size = get_dex_real_size(range.base, range.base, range.base.add(range.size));
            result.push({
                "addr": range.base,
                "size": real_dex_size
            });
        }
    // ... deepSearch 로직
}

verify

추가 검증 로직. 3번째 인자가 false로 들어오면 헤더 크기만 검사한다.

function verify_by_maps(dexptr) {
    var maps_offset = dexptr.add(0x34).readUInt();
    var maps_ptr = dexptr.add(maps_offset)
    var maps_size = maps_ptr.readUInt();

    for (var i = 0; i < maps_size; i++) {
        var item_type = maps_ptr.add(4 + i * 0xC).readU16();
        // item_type == maps 인 경우
        if (item_type === 4096) {
            var map_offset = maps_ptr.add(4 + i * 0xC + 8).readUInt();
            if (maps_offset === map_offset) {
                // 헤더의 maps 오프셋과 maps에서의 maps 오프셋이 같다면 true
                return true;
            }
        }
    }
    return false;
}

function verify(dexptr, range, enable_verify_maps) {
    if (range != null) {
        // 헤더 크기보다 큰지만 검사한다. 
        var range_end = range.base.add(range.size);
        if (dexptr.add(0x70) > range_end) {
            return false;
        }

        if (enable_verify_maps) {
            // 사실 maps_address 를 구하면서 경계검사하는 코드가 있는데, 로직상 의미없음
            // 헤더의 maps와 maps안에서의 maps 오프셋이 같은 주소를 가리키면 true
            return verify_by_maps(dexptr);
        } else {
            // 헤더 사이즈가 0x70으로 세팅되어있는지 확인. 이건 최근까지도 고정값임
            return dexptr.add(0x3C).readUInt() === 0x70;
        }
    }
    return false;
}

get_dex_real_size

원래는 헤더의 0x20에서 dex_size를 구할 수 있는데, 얘는 maps_end - dex_base 로 계산해서 real_size를 구한다.

function get_dex_real_size(dexptr, range_base, range_end) {
    var maps_address = get_maps_address(dexptr, range_base, range_end);
    var maps_end = get_maps_end(maps_address, range_base, range_end);
    return maps_end.sub(dexptr).toInt32();
}

deepSearch

deepSearch가 세팅된 경우 일반 검색은 동일하게 동작하고, 추가 검색을 하는것이다.

  • 일반 검색에서 찾은 dex들은 전부 덤프 대상에 포함
  • 세 조건 모두 맞아야한다.
    • scanSync: 70 00 00 00 패턴으로 헤더 사이즈로 검색 (모든 덱스파일 고정값)
    • verify: 헤더의 maps 오프셋과 maps의 maps오프셋이 같은지 확인
    • verify_ids_off: 헤더의 각 ids 오프셋과 maps의 각 ids오프셋이 같은지 확인
  • dex 의심되는 것을 찾으면 real_size가 맞지 않아도 그냥 현재 매핑영역 끝까지 덤프
function verify_ids_off(dexptr, dex_size) {
    var string_ids_off = dexptr.add(0x3C).readUInt();
    var type_ids_off = dexptr.add(0x44).readUInt();
    var proto_ids_off = dexptr.add(0x4C).readUInt();
    var field_ids_off = dexptr.add(0x54).readUInt();
    var method_ids_off = dexptr.add(0x5C).readUInt();
    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;
}

// 그냥 dex header size 기준으로 70 00 00 00 면 확인하고본다.
Memory.scanSync(range.base, range.size, "70 00 00 00").forEach(function (match) {
    // header size 가 0x3c 위치에 있으니 헤더 시작부분으로 조정
    var dex_base = match.address.sub(0x3C);
    if (dex_base < range.base) {
        return;
    }

    // 어차피 dex인 경우는 이미 일반검색에서 찾았음 
    // 헤더의 maps와 maps의 maps 오프셋이 같은지 확인
    if (dex_base.readCString(4) != "dex\n" && verify(dex_base, range, true)) {
        // dex 파일로 의심되는 영역에서 maps가 완전히 들어맞는지 확인
        var real_dex_size = get_dex_real_size(dex_base, range.base, range.base.add(range.size));
        if (!verify_ids_off(dex_base, real_dex_size)) {
            return;
        }
        result.push({
            "addr": dex_base,
            "size": real_dex_size
        });

        // real_size랑 안맞더라도 덱스 시작점에서 메모리맵 섹션 전체를 덤프
        var max_size = range.size - dex_base.sub(range.base).toInt32();
        if (max_size != real_dex_size) {
            result.push({
                "addr": dex_base,
                "size": max_size
            });
        }
    }
});

덤프 방지 방법 연구

dex 시그니쳐 지우기

아주기본적인연구
그런데 이거 지워도 되는거랑 지우면 안되는거랑 런타임에 지워도되는거랑 이런거 있을걸

설치시점, 로딩시점, 런타임 이 세가지 시점에서 지워도되는게 다를것임

maps를 흩뿌려놓기

뭔가 이론상 아주 크게 dex를 만들어놓고 이리저리 쪼개놓고 오프셋을 바꿔놓으면 될것같다. maps를 앞으로 땡겨놓으면 real_dex_size를 이상하게 만들 수 있을듯

아니면 헤더를 아래에다 놓으면 frida-dexdump녀석이 헤더가 아래인줄알고 시작지점을 잘못잡을 가능성이 높다.
오프셋을 마이너스로 못하나?

Comments

ESC
Type to search...