❓
x86_64向けのELF難読化器を書く
#tech
#Rust
2024-2-11
現在の所属先の1つである,筑波大学情報科学類において卒業のために履修する必要がある主専攻実験では,秋学期に「バイナリプログラムの解析」というテーマを選択した.これはその名前の通り,各回で主にバイナリプログラムを対象とした解析について学ぶ.
この講義は既に終了しており,2023年度における各回の実施内容はここにある[1]のだが,その中で難読化について取り扱う回が存在した.
概ね2週に1回のペースで複数[2]の課題が出て,全体的に手を動かして実装する系の課題が多かったが,特にこの回において出題された,"適当な難読化ツールを作成する(ターゲットはバイナリでもソースコードでも構わない)"といった趣旨の課題において作成した,x86_64向けのELFを難読化するRust製のアプリケーションについては,ブログのネタにするには丁度良い実装量だと思ったのでこうして記事を書いている.
尚,GitHub上のリポジトリはこれである.リポジトリ名はcargo new
する際にヨルシカのカトレアを聴いていたことに由来する.
現状以下:
- エンディアンビットの変更(64bitなのに32bitと偽る)
- アーキテクチャビットの変更(エンディアンを偽る)
- セクションヘッダ情報秘匿
- シンボル情報秘匿
- 入力された任意のセクション情報秘匿
それぞれを行うことでどのような効能が得られるのかについては,実行結果の章に記した.また,各処理は独立となるように作ってあるため,複数の処理を重ね掛けしても良い.
Obfuscator
構造体を作成し,その中で各種操作をimpl
している.構造体の定義はこれ:
pub struct Obfuscator {
input: Mmap,
pub output: MmapMut,
sec_hdr: String,
sec_hdr_num: u64,
sec_hdr_size: u64,
sec_hdr_offset: u64,
sec_table: u64,
}
難読化対象のELFファイル(以下ターゲットファイル)をinput
メンバがmemmap::Mmap
として持っていて,成果物はoutput
メンバがmemmap::MmapMut
として持っている.
他のメンバについては,セクションヘッダに関するもので,sec_hdr
が各セクション名が入った文字列,sec_hdr_num
がセクションヘッダ数,sec_hdr_size
がセクションヘッダサイズ,sec_hdr_offset
がセクションヘッダのオフセット,sec_table
がセクションテーブルのアドレスを示している.
各難読化手法を適用する前段階で,open関数で前処理を行っている.
まず,std::fs::OpenOptions
を用いて権限を適切に割り当てて開く:
let file = match OpenOptions::new().read(true).open(input_path) {
Ok(file) => file,
Err(e) => {
panic!("failed to open file: {}", e);
}
};
let mut output_file = match OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(output_path)
{
Ok(file) => file,
Err(e) => {
panic!("failed to create file: {}", e);
}
};
このままではoutput_file
は空なので,ひとまずターゲットファイルと同じ物を詰める:
let mut input_contents = Vec::new();
file.try_clone()?
.take(usize::MAX as u64)
.read_to_end(&mut input_contents)?;
output_file.write_all(&input_contents)?;
その後memmap::{Mmap, MmapMut}
を用いてメモリマップを作る:
let input = unsafe { Mmap::map(&file)? };
let output = unsafe { MmapMut::map_mut(&output_file)? };
この後は,ターゲットファイルのELFヘッダを舐めてセクション情報を取ってくる.これは
use std::io::mem;
const ELF64_ADDR_SIZE: usize = mem::size_of::<u64>();
const ELF64_OFF_SIZE: usize = mem::size_of::<u64>();
const ELF64_WORD_SIZE: usize = mem::size_of::<u32>();
const ELF64_HALF_SIZE: usize = mem::size_of::<u16>();
のようなものを用意しておき,
const E_TYPE_START_BYTE: usize = 16;
const E_TYPE_SIZE_BYTE: usize = ELF64_HALF_SIZE;
const E_MACHINE_START_BYTE: usize = E_TYPE_START_BYTE + E_TYPE_SIZE_BYTE;
const E_MACHINE_SIZE_BYTE: usize = ELF64_HALF_SIZE;
const E_VERSION_START_BYTE: usize = E_MACHINE_START_BYTE + E_MACHINE_SIZE_BYTE;
const E_VERSION_SIZE_BYTE: usize = ELF64_WORD_SIZE;
const E_ENTRY_START_BYTE: usize = E_VERSION_START_BYTE + E_VERSION_SIZE_BYTE;
const E_ENTRY_SIZE_BYTE: usize = ELF64_ADDR_SIZE;
みたいにしておけばELFヘッダの各フィールドのインデックスを持てる.実際には各フィールドごとにconst
を定義してある.
これができればセクションヘッダ情報を取れて,
let sec_hdr_offset = u32::from_le_bytes(
input[E_SHOFF_START_BYTE..E_SHOFF_START_BYTE + 4]
.try_into()
.unwrap(),
) as u64;
let sec_hdr_num = u16::from_le_bytes(
input[E_SHNUM_START_BYTE..E_SHNUM_START_BYTE + 2]
.try_into()
.unwrap(),
) as u64;
let sec_hdr_size = u16::from_le_bytes(
input[E_SHENTSIZE_START_BYTE..E_SHENTSIZE_START_BYTE + 2]
.try_into()
.unwrap(),
) as u64;
などとすればよい.
また,この段階でセクション一覧を取得することもしているから,これについても説明する.セクション一覧は,セクションヘッダテーブルに書かれているので,これを読み込むようなコードを書くと良い.
let sh_table_header_addr = (u16::from_le_bytes(input[62..64].try_into().unwrap()) as u64
* sec_hdr_size
+ sec_table) as usize;
let sh_table_header =
&input[sh_table_header_addr..sh_table_header_addr + sec_hdr_size as usize];
let sh_table_addr =
u64::from_le_bytes(sh_table_header[24..32].try_into().unwrap()) as usize;
let mut curr_strings = -1;
let mut index = sh_table_addr;
let mut curr_byte;
while curr_strings < sec_hdr_num as isize {
curr_byte = input[index] as isize;
if curr_byte == 0 {
curr_strings += 1;
}
index += 1;
}
sh_table_addr
にはセクションヘッダテーブルの開始アドレスが入っている.このため,sec_hdr_num
個のセクションを発見するまでindex
を増加させることで,ループを抜ける際にindex
にはセクションヘッダテーブルが終了するアドレスが入っていることになる.これにより,セクションヘッダテーブルの中身を取ってこられるため,次のようにしてセクションヘッダテーブルからセクション一覧を示す文字列を構成することができる.
let mut data_copy: Vec<u8> = vec![0; index - sh_table_addr];
data_copy.copy_from_slice(&input[sh_table_addr..index]);
for byte in &mut data_copy {
if *byte == 0 {
*byte = b' ';
}
}
let sec_hdr = String::from_utf8_lossy(&data_copy).to_string();
これにより,セクション名が半角スペース区切りで入ったStringであるsec_hdr
が手に入る.
ここまでの処理が完了したら,open
関数はObfuscator
構造体に必要なメンバを詰めてResult
に包んで返す.
Ok(Obfuscator {
input,
output,
sec_hdr,
sec_hdr_num,
sec_hdr_size,
sec_hdr_offset,
sec_table,
})
以上で前処理は完了.
簡単で,4バイト目が1なら2,2なら1にすれば良いのでこう書ける.
pub fn change_class(&mut self) {
self.output[4] = 3 - self.output[4];
}
入力の5バイト目についてエンディアンと同じことをする.
pub fn change_endian(&mut self) {
self.output[5] = 3 - self.output[5];
}
前処理でターゲットのセクションヘッダ数とセクションヘッダオフセット,セクションヘッダサイズを取得しているからこれを基に埋め立てる.
pub fn nullify_sec_hdr(&mut self) {
for i in 0..self.sec_hdr_num {
let offset = self.sec_hdr_offset + i * self.sec_hdr_size;
for j in offset..offset + self.sec_hdr_size {
self.output[j as usize] = 0;
}
}
}
シンボル情報の秘匿は,ここで述べる処理を.strtab
に対して適用しているだけなので割愛する.
セクション情報を秘匿することを考えると,秘匿対象のセクションについて,その開始アドレスとサイズを知る必要がある.
このため,セクション名を引数に取って,該当セクションの開始アドレスとサイズのタプルを返す関数を雑に書いた:
fn get_section(&self, section: &str) -> (usize, usize) {
let searched_idx = self.sec_hdr.find(section).unwrap_or(usize::MAX);
if searched_idx == usize::MAX {
panic!("section not found");
}
for i in 0..self.sec_hdr_num {
let sec_hdr = self.input[(self.sec_table + i * self.sec_hdr_size) as usize
..(self.sec_table + (i + 1) * self.sec_hdr_size) as usize]
.to_vec();
let string_offset = u32::from_le_bytes(sec_hdr[0..4].try_into().unwrap());
if string_offset == searched_idx as u32 {
return (
u64::from_le_bytes(sec_hdr[24..32].try_into().unwrap()) as usize,
u64::from_le_bytes(sec_hdr[32..40].try_into().unwrap()) as usize,
);
}
}
(usize::MAX, usize::MAX)
}
self.sec_hdr
には,セクション一覧が格納されているのでself.sec_hdr.find(section).unwrap_or(usize::MAX)
として該当セクションのインデックスかusize::MAX
が手に入る.インデックスが手に入れば各セクションについて,インデックスを調べて所望のものであれば開始アドレスとサイズをタプルに詰めて返すだけ.
このタプルが入手できれば,後はやるだけで,(開始アドレス)..(開始アドレス)+(サイズ)
の間を埋め立てるだけで良い:
pub fn nullify_section(&mut self, section: &str) {
let (section_addr, section_size) = self.get_section(section);
if section_addr == usize::MAX {
panic!("section not found");
}
for i in section_addr..section_addr + section_size {
self.output[i] = 0;
}
}
これで任意セクションの情報を秘匿することができる.
壊れるやつを載せる.
readelf -h
,壊れる
ヘッダ情報が異常な数値を示す
$ readelf -h res_endian
ELF Header:
Magic: 7f 45 4c 46 02 02 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, big endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: <unknown>: 300
Machine: <unknown>: 0x3e00
Version: 0x1000000
Entry point address: 0x6010000000000000
Start of program headers: 4611686018427387904 (bytes into file)
Start of section headers: -3443564865078165504 (bytes into file)
Flags: 0x0
Size of this header: 16384 (bytes)
Size of program headers: 14336 (bytes)
Number of program headers: 3328
Size of section headers: 16384 (bytes)
Number of section headers: 7936
Section header string table index: 7680
readelf: Warning: The e_shentsize field in the ELF header is larger than the size of an ELF section header
readelf: Error: Reading 130023424 bytes extends past end of file for section headers
readelf: Error: Too many program headers - 0xd00 - the file is not that big
objdump -d
,壊れる
逆アセンブルができない
$ objdump -d res_endian
objdump: res_endian: file format not recognized
gdb,壊れる
シンボルテーブルがロードできない
gdb-peda$ b main
No symbol table is loaded. Use the "file" command.
readelf -h
,壊れる
ヘッダ情報が異常な数値を示す
$ readelf -h res_64bit
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 0 (bytes into file)
Start of section headers: 64 (bytes into file)
Flags: 0x0
Size of this header: 14032 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 0 (bytes)
Number of section headers: 0
Section header string table index: 0
readelf: Warning: possibly corrupt ELF file header - it has a non-zero section header offset, but no section headers
objdump -d
,壊れる
逆アセンブルができない
$ objdump -d res_64bit
objdump: res_64bit: file format not recognized
readelf -S
,壊れる
セクションヘッダ情報が異常な数値を示す
$ readelf -S res_sechdr
There are 31 section headers, starting at offset 0x36d0:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 2] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 3] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 4] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 5] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 6] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 7] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 8] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 9] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[10] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[11] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[12] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[13] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[14] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[15] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[16] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[17] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[18] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[19] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[20] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[21] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[22] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[23] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[24] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[25] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[26] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[27] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[28] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[29] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[30] <no-strings> NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
readelf: Error: no .dynamic section in the dynamic segment
適当に,.strtab
を秘匿した結果について見てみる.
gdb,壊れる
mainのシンボルを読めない
gdb-peda$ b main
Function "main" not defined.
また,strings
コマンドの出力を見ても,処理後にはシンボル名が出力されないことも見て取れる.
パッカーの機能を入れたいと思っている.パッカーとはプログラムが実行された際の動作は変えることなく,圧縮されたプログラムコードを自身の中に保持しており,実行時にはそれを自己解凍して実行するようなバイナリを作成するソフトウェア.これによりファイルサイズの削減と難読化を達成できる.
x86などの命令長が可変であるようなCPUに対しては,ダミーデータを挿入することによって命令の切れ目を逆アセンブラから分からなくすることができる.例えば,ダミーデータとして,前半には命令として解釈可能なバイト列を,後半には命令の前半のバイト列を格納したり,もしくは命令の途中にジャンプするような命令を導入するなどの手法がある.