PLT Hook 체크를 위한 Android so 파일 파싱
PLT Hook 체크를 위한 Android so 파일 파싱 관련
들어가며
Android PLT(procedure linkage table) hook과 관련해 plt hook 당한 함수를 식별하는 방법을 공유하고자 합니다. plt hook 라이브러리로는 bytedance에서 오픈소스로 공개한 bytedance/bhook
이 많이 사용되고 있는 것으로 보입니다.
실제로 사용해 보니 쉽고, 안정적으로 후킹이 가능합니다. 다음 사진은 fopen 함수를 plt hook하여 로그를 찍은 것입니다.
제가 쉽게 할 수 있다는 건 남들도, 특히 해커들도 쉽게 할 수 있다는 거겠죠? 이러한 후킹은 어뷰징으로 이어질 수 있기 때문에 분석하는 입장에서는 plt hook 당한 함수를 찾아낼 수 있어야겠죠.
PLT hook 하면 무슨 일이 발생하는가?
so 파일은 ELF 파일 포맷을 가지고 있습니다. ELF 파일은 메모리에 로드되면 .rela.plt 섹션에 정의되어 있는 함수(심볼)의 주소값을 .got.plt 섹션에 저장합니다. 그 후 .got.plt 섹션에 저장되어 있는 주소를 참조하여 해당 함수를 호출하게 됩니다.
다음은 libart.so
파일을 로컬에서 elf 파싱(horsicq/XELFViewer
) 프로그램으로 열어 본 것입니다. .rela.plt
섹션에 0x212
라는 심볼이 정의되어 있고, r_offset
값이 0x6a6480
입니다. r_offset
은 libart.so
파일이 메모리에 로드되면 그 base 주소로부터 0x6a6480
위치만큼 떨어진 곳에 0x212
심볼의 주소값을 저장하겠다는 뜻입니다.
0x212 심볼은 무엇을 의미할까요? .dynsym
섹션에서 찾을 수 있습니다. 0x212
는 십진수로 530입니다. 530은 .dynsym
섹션의 인덱스입니다. 즉, .dynsym
섹션에서 530번째 저장되어 있는 심볼인 것이죠. fopen
이네요.
정리하면 "fopen
함수는 .rela.plt 섹션에 정의되어 있고, libart.so
파일이 메모리에 로드되면 base 주소로부터 0x6a6480
위치 떨어진 곳에 fopen
함수의 주소를 저장한다. 그곳의 위치는 .got.plt
섹션에 있다"가 되겠습니다.
만약 fopen
함수가 plt hook 당하면 .got.plt
섹션에 저장되는 fopen
함수의 주소가 다른 곳(악성 so 파일의 함수 주소)으로 바뀌게 될 것입니다.
PLT hook 당한 함수를 어떻게 식별하는가?
런타임 시에 특정 함수가 plt hook을 당하면 .got.plt
섹션에 저장되는 주소값이 바꿔치기 당할 것입니다. .got.plt
섹션에 주소값을 저장하는 심볼들은 .rela.plt
섹션에 정의되어 있습니다. .rela.plt
섹션에는 어떤 심볼 및 그 심볼의 주소값이 어느 위치에 저장되는지 나와 있습니다.
그러니, .rela.plt
섹션을 파싱해서 어떤 심볼의 주소값이 악성 so
파일에 위치하고 있으면 plt hook 당한 것으로 판단할 수 있습니다.
.rela.plt
섹션을 어떻게 파싱할까요? 그냥 open
함수로 so
파일 열어서 파싱하면 되는 것 아닐까요? 그러나 문제가 있습니다. android:extractNativeLibs="false"
옵션으로 빌드한 apk 파일의 경우 so 파일을 apk 파일에서 추출하지 않고 로드하기 때문에 open
함수로 열어볼 수가 없습니다.
다음은 어느 게임 앱의 메모리에 로드된 libil2cpp.so
파일 정보인데요. 그 경로가 split_config_arm64_v8a.apk
파일 안에 있는 것을 알 수 있습니다(! 표시가 apk
파일 안에 있는 경로를 의미함).
흠...그래도 문제없죠. 어차피 so
파일은 메모리에 로드되는 것이니 메모리에서 직접 .rela.plt 섹션을 파싱하면 될 테니까요. 그러면 .rela.plt 섹션이 메모리의 어디에 위치하고 있는지 알아내면 되겠습니다. .rela.plt
섹션의 위치는 Section Header라는 곳에 정의되어 있습니다. 그 Section Header의 위치는 Elf Header(Elf_Ehdr)에 정의되어 있습니다. 로컬에서 libart.so
파일의 Elf Header 중 e_shoff
값이 0x836a98
입니다. 그곳이 Section Header의 위치입니다.
자, 이제 Section Header 위치로 직접 가서 메모리를 관찰해 봐야겠습니다.
아뿔싸! Memory access violation이 발생하면서 접근이 안 됩니다...무슨 문제일까요?
런타임 시에 Seciton Header도 메모리에 로드되는 것으로 알고 접근하였는데, 찾아보니 Section Header는 메모리에 로드되지 않는다고 합니다(출처: https://stackoverflow.com/a/44854052).
난감하네요. Section Header 에 .rela.plt
등 섹션 위치가 정의되어 있는데, 이게 메모리에 로드되지 않는다면 어떻게 위치를 알아내고 파싱을 할 수 있을까요? 다행히 Program Header는 메모리에 로드가 됩니다.
이제, 섹션들의 메모리상의 위치 정보를 확보했으니 파싱만 하면 되겠군요.
다음은 어느 게임 앱의 fopen
함수를 plt hook 한 뒤, libil2cpp.so
파일의 .rela.plt 섹션을 메모리상에서 파싱한 결과입니다. 음...fopen
함수의 주소가 0x72079708e8
인데, 그 위치가 libplthooktest.so
파일이네요. 원래라면 위치가 libc.so
가 되어야 할 것입니다. 따라서 fopen
함수가 plt hook 당한 것으로 판단할 수 있습니다.
파싱에 사용된 frida 스크립트 공유합니다.
var magic = "464c457f"
function parseElf(base) {
base = ptr(base)
// Read elf header
var elf_magic = base.readU32()
if (parseInt(elf_magic).toString(16) != magic) {
console.log("Wrong magic")
}
var arch = Process.arch
var is32bit = arch == "arm" ? 1 : 0 // 1:32 0:64
var size_of_Elf32_Ehdr = 0x34;
var off_of_Elf32_Ehdr_phoff = 28;
var off_of_Elf32_Ehdr_shoff = 32;
var off_of_Elf32_Ehdr_phentsize = 42;
var off_of_Elf32_Ehdr_phnum = 44;
var off_of_Elf32_Ehdr_shentsize = 46;
var off_of_Elf32_Ehdr_shnum = 48;
var off_of_Elf32_Ehdr_shstrndx = 50;
var size_of_Elf64_Ehdr = 0x40;
var off_of_Elf64_Ehdr_phoff = 32;
var off_of_Elf64_Ehdr_shoff = 40;
var off_of_Elf64_Ehdr_phentsize = 54;
var off_of_Elf64_Ehdr_phnum = 56;
var off_of_Elf64_Ehdr_shentsize = 58;
var off_of_Elf64_Ehdr_shnum = 60;
var off_of_Elf64_Ehdr_shstrndx = 62;
var got_plt_secition_addr = null;
var dynamic_section_addr = null;
var dynstr_section_addr = null;
var dynsym_section_addr = null;
var rela_plt_section_addr = null;
// Parse Ehdr(Elf header)
var phoff = is32bit ? size_of_Elf32_Ehdr : size_of_Elf64_Ehdr // Program header table file offset
var shoff = is32bit ? base.add(off_of_Elf32_Ehdr_shoff).readU32() : base.add(off_of_Elf64_Ehdr_shoff).readU64(); // Section header table file offset
var phentsize = is32bit ? base.add(off_of_Elf32_Ehdr_phentsize).readU16() : base.add(off_of_Elf64_Ehdr_phentsize).readU16(); // Size of entries in the program header table
if (is32bit && phentsize != 32) { // 0x20
console.log("[*] Wrong e_phentsize. Should be 32. Let's assume it's 32");
phentsize = 32;
} else if (!is32bit && phentsize != 56) {
console.log("Wrong e_phentsize. Should be 56. Let's assume it's 56");
phentsize = 56;
}
var phnum = is32bit ? base.add(off_of_Elf32_Ehdr_phnum).readU16() : base.add(off_of_Elf64_Ehdr_phnum).readU16(); // Number of entries in program header table
if (phnum == 0) {
console.log("phnum is 0. Let's assume it's 10. because we just need to find .dynamic section")
phnum = 10;
}
var shentsize = is32bit ? base.add(off_of_Elf32_Ehdr_shentsize).readU16() : base.add(off_of_Elf64_Ehdr_shentsize).readU16(); // Size of the section header
if (is32bit && shentsize != 40) { // 0x28
console.log("Wrong e_shentsize. Should be 40");
} else if (!is32bit && shentsize != 64) {
console.log("Wrong e_shentsize. Should be 64");
}
var shnum = is32bit ? base.add(off_of_Elf32_Ehdr_shnum).readU16() : base.add(off_of_Elf64_Ehdr_shnum).readU16(); // Number of entries in section header table
var shstrndx = is32bit ? base.add(off_of_Elf32_Ehdr_shstrndx).readU16() : base.add(off_of_Elf64_Ehdr_shstrndx).readU16(); // Section header table index of the entry associated with the section name string table
// console.log(`phoff: ${phoff}, shoff: ${shoff}, phentsize: ${phentsize}, phnum: ${phnum}, shentsize: ${shentsize}, shnum: ${shnum}, shstrndx: ${shstrndx}`)
// Parse Phdr(Program header)
var phdrs = base.add(phoff)
for (var i = 0; i < phnum; i++) {
var phdr = phdrs.add(i * phentsize);
var p_type = phdr.readU32()
var p_offset = is32bit ? phdr.add(0x4).readU32() : phdr.add(0x8).readU64();
var p_vaddr = is32bit ? phdr.add(0x8).readU32() : phdr.add(0x10).readU64();
var p_paddr = is32bit ? phdr.add(0xc).readU32() : phdr.add(0x18).readU64();
var p_filesz = is32bit ? phdr.add(0x10).readU32() : phdr.add(0x20).readU64();
var p_memsz = is32bit ? phdr.add(0x14).readU32() : phdr.add(0x28).readU64();
var p_flags = is32bit ? phdr.add(0x18).readU32() : phdr.add(0x4).readU32();
var p_align = is32bit ? phdr.add(0x1c).readU32() : phdr.add(0x30).readU64();
// console.log(`p_type: ${p_type}, p_offset: ${p_offset}, p_vaddr: ${p_vaddr}, p_paddr: ${p_paddr}, p_filesz: ${p_filesz}, p_memsz: ${p_memsz}, p_flags: ${p_flags}, p_align: ${p_align}`);
if (p_type == 0x2) {
// .dynamic
dynamic_section_addr = base.add(p_vaddr);
var dynamic_section_indices = parseInt(p_memsz) / parseInt(p_align) * 2
var dynamic_section_entsize = p_align * 2
for (var i = 0; i < dynamic_section_indices; i++) {
var d_tag = is32bit ? dynamic_section_addr.add(i * dynamic_section_entsize).readU32() : dynamic_section_addr.add(i * dynamic_section_entsize).readU64()
if (d_tag == 0) break;
var d_value = is32bit ? dynamic_section_addr.add(i * dynamic_section_entsize + 4).readU32() : dynamic_section_addr.add(i * dynamic_section_entsize + 8).readU64()
if (d_tag == 0x3) {
// .got.plt
got_plt_secition_addr = base.add(d_value);
} else if (d_tag == 0x5) {
// .dynstr
dynstr_section_addr = base.add(d_value);
} else if (d_tag == 0x6) {
// .dynsym
dynsym_section_addr = base.add(d_value);
} else if (d_tag == 0x17) {
// .rela.plt
rela_plt_section_addr = base.add(d_value);
}
}
}
}
// Parse .dynsym
var dynsym_section_entsize = is32bit ? 0x10 : 0x18;
var dynsyms = {};
var st_infos = [
0x00, // LOCAL NOTYPE
0x03, // LOCAL SECTION
0x10, // GLOBAL NOTYPE
0x11, // GLOBAL OBJECT
0x12, // GLOBAL FUNC
0x1a, // GLOBAL LOOS
0x20, // WEAK NOTYPE
0x21, // WEAK OBJECT
0x22, // WEAK FUNC
]
var st_others = [
0x0, /* STV_DEFAULT. Default symbol visibility rules */
0x1, /* STV_INTERNAL. Processor specific hidden class */
0x2, /* STV_HIDDEN. Sym unavailable in other modules */
0x3, /* STV_PROTECTED. Not preemptible, not exported */
]
for (var i = 0, id = 0;;i += dynsym_section_entsize, id++) {
var dynsym_section_entaddr = dynsym_section_addr.add(i)
var st_name = is32bit ? dynsym_section_entaddr.readU32() : dynsym_section_entaddr.readU32();
var st_value = is32bit ? dynsym_section_entaddr.add(0x4).readU32() : dynsym_section_entaddr.add(0x8).readU64();
var st_size = is32bit ? dynsym_section_entaddr.add(0x8).readU32() : dynsym_section_entaddr.add(0x10).readU64();
var st_info = is32bit ? dynsym_section_entaddr.add(0xc).readU8() : dynsym_section_entaddr.add(0x4).readU8();
if (!st_infos.includes(st_info)) {
console.log(`st_info: ${st_info} is not a valid`)
break;
}
var st_other = is32bit ? dynsym_section_entaddr.add(0xd).readU8() : dynsym_section_entaddr.add(0x5).readU8();
if (!st_others.includes(st_other)) {
console.log(`st_ohter: ${st_other} is not a valid`)
break;
}
var st_shndx = is32bit ? dynsym_section_entaddr.add(0xe).readU16() : dynsym_section_entaddr.add(0x6).readU16();
try {
var symbol_name = dynstr_section_addr.add(st_name).readUtf8String();
} catch (error) {
break;
}
dynsyms[id] = {
"symbol_name": symbol_name,
"st_value": st_value,
"st_size": st_size,
"st_info": st_info,
"st_other": st_other,
"st_shndx": st_shndx
}
// console.log(`${id}. st_name: ${st_name} --> ${symbol_name}, st_value: ${st_value}, st_size: ${st_size}, st_info: ${st_info}, st_other: ${st_other}, st_shndx: ${st_shndx}`)
}
// Parse .rela.plt
var rela_plt_section_entsize = is32bit ? 0x8 : 0x18
var R_ARM_JUMP_SLOT = 0x16 /* Create PLT entry */
var R_AARCH64_JUMP_SLOT = 0x402
for (var i = 0, id = 0;;i += rela_plt_section_entsize, id++) {
var rela_plt_section_entaddr = rela_plt_section_addr.add(i);
var r_offset = is32bit ? rela_plt_section_entaddr.readU32() : rela_plt_section_entaddr.readU64();
var r_info_addr = is32bit ? rela_plt_section_entaddr.add(0x4) : rela_plt_section_entaddr.add(0x8);
var reloc_type = is32bit ? r_info_addr.readU8() : r_info_addr.readU32();
if ((is32bit && reloc_type != R_ARM_JUMP_SLOT) || (!is32bit && reloc_type != R_AARCH64_JUMP_SLOT)) {
break;
}
var sym_index = is32bit ? r_info_addr.readU32() > 8 : r_info_addr.add(0x4).readU32();
var symptr_in_got_plt = base.add(r_offset).readPointer();
var r_addend = rela_plt_section_entaddr.add(0x10).readU64();
var location = Process.findModuleByAddress(symptr_in_got_plt) === null ? 'None' : Process.findModuleByAddress(symptr_in_got_plt).name
console.log(`${id}. symbol: ${dynsyms[sym_index]["symbol_name"]} --> addr: ${symptr_in_got_plt}(${location})`)
}
}
parseElf(Module.findBaseAddress("libil2cpp.so"))
긴 글을 읽어 주셔서 감사합니다.