Memory dex dump 방지 연구
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
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