iOS代码混淆

标识符混淆

念大婶在博客中介绍了两种方法,用于保护代码逻辑,对抗逆向分析

  • 代码混淆 通过宏定义,混淆objective-c消息(函数),用于对抗class-dump。
  • 敏感逻辑用C实现 通过static关键字和函数指针的方式,将关键逻辑隐藏,可以对抗class-dump和Cycript攻击。

如果用了第二种方式,将函数改用c实现,虽然通过class-dump得不到有价值的信息,但通过nm命令或者IDA/Hopper等工具仍然能从符号表中找到这些c函数以及衍生出的一些静态变量。针对这种情况,我们还是可以通过宏定义的方式,将这些c的标识符(函数名、变量名)替换为随机字符串。

举个例子:

1
2
3
4
5
6
7
8
9
#define func1 gtBFTcseXSElp
#define func2 yNGYcdrCDEzaqZAQki
#define globalValue uNHUvfrVFRxawXAWlo
int globalValue;
void func1() {
}
void func2(int i) {
    func1();
}

nm检查符号表,结果如下

1
2
3
0000000000000000 T _gtBFTcseXSElp
0000000000000004 C _uNHUvfrVFRxawXAWlo
0000000000000010 T _yNGYcdrCDEzaqZAQki

说明宏替换对于c的标识符同样有效。但是要一个个手动去define,感觉是要累死的节奏。如果能通过一个脚本,自动从源代码里把所有的标识符声明提取出来,生成一个头文件就好了。可以考虑几种方案:

  1. 使用正则表达式,根据标识符的声明语法提取
  2. 先解析为语法树,再提取标识符节点
  3. 给需要混淆的符号打个标记

很显然,前两种方案都很繁琐,不好维护。并且如果我要做一个library给第三方使用,必然要暴露一些接口不能被混淆,只有第三种方式可以灵活地选择那些需要混淆哪些不需要,而这种方案实现起来也最简单。最终实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//test.c

#ifdef SYMBOL_OBFUSCATE // 通过外部宏定义控制是否混淆

#include "symbols.h"  // 引入生成的混淆头文件
#define SYMBOL(name) asm(name) // 使用asm label语法修改符号名称

#else 

#define SYMBOL(name)  // 将宏定义为空,即不混淆

#endif

// 声明并标记需要混淆的符号
int globalValue SYMBOL(_globalValue);
void func1() SYMBOL(_func1);
void func2(int a) SYMBOL(_func2);
void func3();    // 不混淆

// 以下不需要做任何处理,保持原样即可
void func1() {

}


void func2(int a) {
    func1();
}

void func3() {

}

使用asm label语法的好处是,只需要将符号的声明标记出来进行替换即可, 不需要对该符号的引用进行标记和替换。如果要混淆已经完成的代码,这一点非常省时省力。

扫描源代码并生成混淆头文件的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#!/bin/bash
# 本脚本用于对源代码中的函数名及全局变量名进行混淆,生成映射文件

# usage: rand a b
# 生成[a, b)之间的随机数
function rand(){
    min=$1
    max=$(($2-$min))
    num=$(($RANDOM+1000000000))
    echo $(($num%$max+$min))
}

# 生成随机字符
function rand_c() {
    base="qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_$"
    echo ${base:$(rand 0 54):1}
}

# 生成16-32长度的随机变量名
function rand_s() {
    symbol=""
    for i in $(seq $(rand 16 33)); do
        symbol=$symbol$(rand_c)
    done
    echo $symbol
}

file=$2
src=$1

# 生成文件头,注释
cat > $file << EOF
//
//  $file
//

/*
 * This is the symbol substitution mapping file.
 * Auto-generated by $0, from the source file $src.
 * You can change the value of macro defination freely, but DO NOT DELETE any of them.
 */

EOF

# 提取源文件中所有的SYMBOL(_xxx)宏,并生成随机标识符
cat $src | sed -n "s/.*SYMBOL(\(_.*\)).*/\1/p" | while read symbol
do
    rand_symbol=`rand_s`
    echo -e "\033[32m$symbol\033[m -> \033[33m$rand_symbol\033[m"
    echo "#define $symbol \"$rand_symbol\"" >> $file
done

exit

测试一下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#正常编译并查看符号表
$ clang -c test.c -o test.o && nm test.o
0000000000000000 T _func1
0000000000000010 T _func2
0000000000000030 T _func3
0000000000000004 C _globalValue

#扫描源代码,生成混淆头文件
$ ./obfuscate.sh test.c symbols.h
_globalValue -> vftVFRxswXAWlo$LOmhu
_func1 -> UvftVFTxsweCSElpqLPmjiMJIb
_func2 -> BGTcseCSEzpqLPQjiM

#查看生成的头文件
$ tail -n 3 symbols.h
#define _globalValue "vftVFRxswXAWlo$LOmhu"
#define _func1 "UvftVFTxsweCSElpqLPmjiMJIb"
#define _func2 "BGTcseCSEzpqLPQjiM"

#混淆编译,并查看符号表
$ clang -DSYMBOL_OBFUSCATE -c test.c -o test.o && nm test.o
0000000000000010 T BGTcseCSEzpqLPQjiM
0000000000000000 T UvftVFTxsweCSElpqLPmjiMJIb
0000000000000030 T _func3
0000000000000004 C vftVFRxswXAWlo$LOmhu

如果你有点懵,可以看一下混淆的过程是怎样的

1
2
3
4
5
6
7
8
9
void func1() SYMBOL(_func1);    ==>   void func1();
           ||               不混淆时的展开
          \||/ SYMBOL宏展开
           \/
void func1() asm(_func1);
           ||
          \||/ _func1宏展开
           \/
void func1() asm("UvftVFTxsweCSElpqLPmjiMJIb");

asm label的语法解释,可以参考gcc的onlinedocs

字符串混淆

字符串也是逆向分析的一大切入点,可以根据目标字符串快速定位目标代码,有针对性地进行调试、分析。在binary中隐藏字符串可以有效提升静态分析的难度,因此需要在源代码中将字符串进行加密,运行时先解密后再使用。但如果在源代码中直接写加密后的字符串,代码的可读性就会变得非常差。

但字符串无法像标识符那样,在预编译阶段直接通过几个宏就替换为加密的形式。我想了一个不是很优雅,但是很有效的方法:

  1. 将源代码中的字符串通过函数宏手动标记
  2. 备份源代码
  3. 将源代码中所有标记过的字符串,替换成decrypt("密文")的形式
  4. 在适当的位置,插入decrypt函数的实现(或者事先在源代码中写好)
  5. 编译
  6. 还原备份的源代码

示例,混淆这份代码中的字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>

#ifndef STRING_OBFUSCATE

#define NSSTRING(string) @string
#define CSTRING(string) string

#endif

int main() {
    NSLog(@"%@", NSSTRING("Hello, world!"));
    printf("%s\n", CSTRING("Hello, world!"));
}

反编译的结果

字符串混淆脚本,字符串加密选用简单的抑或,仅为示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 本脚本用于对源代码中的字符串进行加密
# 1. 在源代码中插入解密函数decryptConstString
# 2. 插入宏,替换所有的NSSTRING(...)和CSTRING(...)为decryptConstString(encrypted_string)
# 3. 替换所有字符串常量为加密的char数组,形式((char[]){1, 2, 3, 0})

import sys
import re
import os

# 插入宏和解密函数,解密方法:每个字节与0xAA异或
insert_code = '''#define STRING_OBFUSCATE

static char* decryptConstString(char* string) __attribute__((always_inline));

#define NSSTRING(string) [NSString stringWithUTF8String:decryptConstString(string)]
#define CSTRING(string) decryptConstString(string)

static char* decryptConstString(char* string)
{
    char* origin_string = string;
    while(*string) {
        *string ^= 0xAA;
        string++;
    }
    return origin_string;
}

#ifndef STRING_OBFUSCATE'''

# 替换字符串为((char[]){1, 2, 3, 0})的形式,同时让每个字节与0xAA异或进行加密
def replace(match):
    # print match.group()
    string = match.group(2) + '\x00'

    replaced_string = '((char []) {' + ', '.join(["%i" % ((ord(c) ^ 0xAA) if c != '\0' else 0) for c in list(string)]) + '})'
    # print replaced_string
    return match.group(1) + replaced_string + match.group(3)

# 修改源代码,加入字符串加密的函数
def obfuscate(file):
    with open(file, 'r') as f:
        code = f.read()
        f.close()
        code = re.sub(r'(NSSTRING\(|CSTRING\()"(.*?)"(\))', replace, code)
        code = code.replace('#ifndef STRING_OBFUSCATE', insert_code)
        # print code
        with open(file, 'w') as f:
            f.write(code)
            f.close()

if __name__ == '__main__':
    if len(sys.argv) == 2 and os.path.exists(sys.argv[1]):
        obfuscate(sys.argv[1])
    else:
        sys.exit()

执行字符串混淆脚本,源代码变为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#import <Foundation/Foundation.h>

#define STRING_OBFUSCATE

static char* decryptConstString(char* string) __attribute__((always_inline));

#define NSSTRING(string) [NSString stringWithUTF8String:decryptConstString(string)]
#define CSTRING(string) decryptConstString(string)

static char* decryptConstString(char* string)
{
    char* origin_string = string;
    while(*string) {
        *string ^= 0xAA;
        string++;
    }
    return origin_string;
}

#ifndef STRING_OBFUSCATE

#define NSSTRING(string) @string
#define CSTRING(string) string

#endif

int main() {
    NSLog(@"%@", NSSTRING(((char []) {226, 207, 198, 198, 197, 134, 138, 221, 197, 216, 198, 206, 139, 0})));
    printf("%s\n", CSTRING(((char []) {226, 207, 198, 198, 197, 134, 138, 221, 197, 216, 198, 206, 139, 0})));
}

测试一下效果

1
2
3
4
5
$ python obfuscate.py string.m
$ clang string.m -framework Foundation
$ ./a.out
2017-01-01 19:34:17.144 a.out[3563:143969] Hello, world!
Hello, world!

反编译一下,已经隐藏了字符串特征

__cstring中也看不到原始的字符串,连混淆后的字符串也看不到

说明:

如果把字符串"Hello"转化为char[]{'H','e','l','l','o',0}的形式进行编译,字符串就会从__cstring中的明文字符,变为__text中的一段代码,可以防止被搜索到。因此如果要兼顾执行效率和混淆的效果,只需要把字符串转换成char数组的形式就可以了,不需要再添加解密的步骤。

Comments