如何防止客户端被破解

 

很多应用都需要用户登录或者签名认证,这可能需要在客户端保存登录信息、签名密钥、加密算法等。如何保证这些重要信息不被窃取,算法不被破解,这些成为应用开发中很重要的内容,同样也是最容易忽视的地方。一个小小的细节可能就成为整个系统的突破口,这里从实际工程角度总结了一些容易忽视的细节和常用的方法。

密钥保存在外部

  • Keychain

    密钥保存在Keychain并不安全,iOS越狱后可以导出Keychain的内容。应该尽量避免存放重要信息(如:token、用户名、密码等)在Keychain中,即使要存放,也一定要加密后存放。参考http://blog.csdn.net/yiyaaixuexi/article/details/18404343

  • 文件

    保存在app bundle、plist等配置文件更不安全,但可以使用隐写术等方式迷惑hackers。有请Lena示范:

     

    两张图片看起来是一模一样的,但是右边的图片里却夹带了一些其他内容,这就是潜伏在Lena中的密码,用diff工具比较下这两张图片,你会发现不同的地方是右边的图片最后附加了一串字符:app secret is "abcdefg123456"。 这里的隐写方式很简单:cat file >> Lena.jpg,既不破坏图片原本的信息(或者损失一点点原有信息),又能附加额外的信息,这就是隐写术的原理。这里只是一个简单的例子,没有人真这么使用。有很多更隐蔽的做法,比如把要隐藏的信息分散到图片的每个像素中,例如RGB888的图片,对红蓝分量最后一个bit位进行修改并不会影响图片的质量(因为人眼对对红蓝不敏感),这样一个像素(3byte)就可以存储2bit的信息,4个像素(12byte)就可以夹带1byte的信息了。

    Xcode打包时会对png图片做特殊处理,如果将密码携带在png中,可能会在使用的时候无法复原。当然现在的隐写术非常多,不只是图片能作为载体,视频、音乐等文件都可以,隐写的方法也多种多样,选择适合自己的就行,据说基地组织就是通过岛国电影传递信息的。

写在代码里安全吗?

下面的代码很常见

1
2 3 4 
 #define kSecret "abcd1234"   // 或者  const char* kSecret = "abcd1234"; 

这是非常危险的,因为常量会被直接编译到可执行文件的data段,只要对生成的可执行文件使用stringsotool等命令就可以dump出原始字符串。

对密码加密

为了使密码不直接出现在可执行文件中,可以对密码加密存储,使用的时候再解密。 例如用AES对密码abcd1234加密,对称密钥为kCipherKey="abcdefgh12345678",加密后的密码用kSecret表示。使用密码时,再通过kCipherKeykSecret计算出来:

snippet1 
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 
const char* kCipherKey="abcdefgh12345678"; const char* kSecret="\x7e\x77\x64\x3c\xa7\xd4\x6d\x46\x29\x8b\xe3\x23\x9f\x1a\x5c\xdb";  char* getSecret() {  char* buf = NULL;  CCCryptorRef cryptor = NULL;  uint8_t iv[kCCBlockSizeAES128];  memset(iv, 0, kCCBlockSizeAES128);   size_t bufsize = 0;  size_t moved = 0;  size_t total = 0;   size_t inLength = strlen(kSecret);   CCCryptorCreate(kCCDecrypt, kCCAlgorithmAES128,  kCCOptionPKCS7Padding,  kCipherKey, strlen(kCipherKey),  iv, &cryptor);  bufsize = CCCryptorGetOutputLength(cryptor, inLength, true);  buf = (char*)malloc(bufsize);  memset(buf, 0, bufsize);   CCCryptorUpdate(cryptor,  kSecret,inLength,  buf, bufsize, &moved);  total += moved;   CCCryptorFinal(cryptor,  buf+total,  bufsize-total, &moved);  CCCryptorRelease(cryptor);   return buf; }   int main(int argc, char * argv[]) {  char* secret = getSecret();  printf("%s\n", secret);  free(secret); } 

上面的代码不再明文出现abcd1234,而是被加密算子kCipherKey和加密后的密钥kSecret替代,密码只是在需要的时候临时计算出来。但是这里仍然有缺陷:加密算子kCipherKey和加密后的密钥kSecret仍然存储在可执行文件的data段中,留下了蛛丝马迹。我们可以给kCipherKey取一个有迷惑性的字符串,比如"network error, timeout"或者使用非字符值,使其不可读。但这都不完美,不在data段中存储这些信息最好。

参数传递的秘密

上面的代码稍做修改

snippet2 
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 
// 注意这里 #define kCipherKey ((uint8_t[]){'a','b','c','d','e','f','g','h','1','2','3','4','5','6','7','8'}) #define kSecret ((uint8_t[]){0x7e,0x77,0x64,0x3c,0xa7,0xd4,0x6d,0x46,0x29,0x8b,0xe3,0x23,0x9f,0x1a,0x5c,0xdb})  char* getSecret() {  char* buf = NULL;  CCCryptorRef cryptor = NULL;  uint8_t iv[kCCBlockSizeAES128];  memset(iv, 0, kCCBlockSizeAES128);   size_t bufsize = 0;  size_t moved = 0;  size_t total = 0;   size_t inLength = sizeof(kSecret);   CCCryptorCreate(kCCDecrypt, kCCAlgorithmAES128,  kCCOptionPKCS7Padding,  kCipherKey, sizeof(kCipherKey),  iv, &cryptor);  bufsize = CCCryptorGetOutputLength(cryptor, inLength, true);  buf = (char*)malloc(bufsize);  memset(buf, 0, bufsize);   CCCryptorUpdate(cryptor,  kSecret,inLength,  buf, bufsize, &moved);  total += moved;   CCCryptorFinal(cryptor,  buf+total,  bufsize-total, &moved);  CCCryptorRelease(cryptor);   return buf; }  int main(int argc, char * argv[]) {  char* secret = getSecret();  printf("%s\n", secret);  free(secret); } 

看似和上面代码没什么区别,除了传入的参数类型变了,其余没什么变化。正是这一点带来了巨大的变化,对比一下调用CCCryptorCreate时的汇编代码:

snippet1-disassemble
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 
Demo`getSecret at main.m:58: 0x31f04: push {r4, r5, r6, r7, lr} 0x31f06: add r7, sp, #0xc 0x31f08: push.w {r8, r10, r11} 0x31f0c: sub sp, #0x28 0x31f0e: movw r0, #0x112a 0x31f12: vmov.i32 q8, #0x0 0x31f16: movt r0, #0x0 0x31f1a: movw r8, #0x16d8 0x31f1e: add r0, pc 0x31f20: movt r8, #0x0 0x31f24: add r8, pc 0x31f26: add r6, sp, #0x14 0x31f28: ldr.w r10, [r0] 0x31f2c: ldr.w r0, [r10] 0x31f30: str r0, [sp, #0x24] 0x31f32: movs r0, #0x0 0x31f34: str r0, [sp, #0x10] 0x31f36: str r0, [sp, #0xc] 0x31f38: ldr.w r0, [r8] 0x31f3c: vst1.32 {d16, d17}, [r6] 0x31f40: blx 0x32ffc ; symbol stub for: strlen 0x31f44: mov r4, r0 0x31f46: movw r0, #0x16aa 0x31f4a: movt r0, #0x0 0x31f4e: add r0, pc 0x31f50: ldr r5, [r0] 0x31f52: mov r0, r5 0x31f54: blx 0x32ffc ; symbol stub for: strlen 0x31f58: add r1, sp, #0x10 0x31f5a: stm.w sp, {r0, r6} 0x31f5e: movs r0, #0x1 0x31f60: str r1, [sp, #0x8] 0x31f62: movs r1, #0x0 0x31f64: movs r2, #0x1 0x31f66: mov r3, r5 0x31f68: blx 0x32fd4 ; symbol stub for: CCCryptorCreate 
snippet2-disassemble 
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 58 59 60 
Demo`getSecret at main.m:23: 0x4de84: push {r4, r5, r6, r7, lr} 0x4de86: add r7, sp, #0xc 0x4de88: push.w {r8, r10, r11} 0x4de8c: sub sp, #0x3c 0x4de8e: movw r0, #0x11a6 0x4de92: movs r1, #0x0 0x4de94: movt r0, #0x0 0x4de98: movs r6, #0x64 0x4de9a: add r0, pc 0x4de9c: vmov.i32 q8, #0x0 0x4dea0: ldr r5, [r0] 0x4dea2: ldr r0, [r5] 0x4dea4: str r0, [r7, #-28] 0x4dea8: sub.w r0, r7, #0x2c 0x4deac: str r1, [r7, #-80] 0x4deb0: str r1, [r7, #-84] 0x4deb4: movs r1, #0x61 0x4deb6: strb r1, [r7, #-60] 0x4deba: movs r1, #0x62 0x4debc: strb r1, [r7, #-59] 0x4dec0: movs r1, #0x63 0x4dec2: strb r1, [r7, #-58] 0x4dec6: movs r1, #0x65 0x4dec8: strb r6, [r7, #-57] 0x4decc: strb r1, [r7, #-56] 0x4ded0: movs r1, #0x66 0x4ded2: strb r1, [r7, #-55] 0x4ded6: movs r1, #0x67 0x4ded8: strb r1, [r7, #-54] 0x4dedc: movs r1, #0x68 0x4dede: strb r1, [r7, #-53] 0x4dee2: movs r1, #0x31 0x4dee4: strb r1, [r7, #-52] 0x4dee8: movs r1, #0x32 0x4deea: strb r1, [r7, #-51] 0x4deee: movs r1, #0x33 0x4def0: strb r1, [r7, #-50] 0x4def4: movs r1, #0x34 0x4def6: strb r1, [r7, #-49] 0x4defa: movs r1, #0x35 0x4defc: strb r1, [r7, #-48] 0x4df00: movs r1, #0x36 0x4df02: strb r1, [r7, #-47] 0x4df06: movs r1, #0x37 0x4df08: strb r1, [r7, #-46] 0x4df0c: movs r1, #0x38 0x4df0e: vst1.32 {d16, d17}, [r0] 0x4df12: strb r1, [r7, #-45] 0x4df16: sub sp, #0xc 0x4df18: movs r2, #0x10 0x4df1a: sub.w r1, r7, #0x50 0x4df1e: sub.w r3, r7, #0x3c 0x4df22: str r2, [sp] 0x4df24: str r0, [sp, #0x4] 0x4df26: movs r0, #0x1 0x4df28: str r1, [sp, #0x8] 0x4df2a: movs r1, #0x0 0x4df2c: movs r2, #0x1 0x4df2e: blx 0x4efdc ; symbol stub for: CCCryptorCreate 

注意CCCryptorCreate的第四个参数,对应寄存器r3,第一段代码的r3的值是从text段直接获取,因为这只是data段的相对地址,编译时就确定了。而再看第二段代码,出现了大量的strb指令,分析知这段指令是把abcdefgh12345678每个字符逐个压进执行栈的连续地址中,然后r3取相应的连续地址的首地址。也就是说kCipherKey不再直接存储在data段,而是打散到多个指令中,成为指令的一部分(指令在text段),当代码运行时,这些指令再把kCipherKey原始内容逐个压入执行栈中构成字符串,然后用栈中字符串首地址作为参数传给CCCryptorCreate,这使得每次调用时传入的字符串地址都不同。函数CCCryptorUpdate原理也是一样。函数getSecret()执行完之后,他的执行栈被清空,kCipherKeykSecret原始信息也一起从栈中清楚,这样重要信息不会常驻内存,只是用到时才进入内存,用完立即清除,这可以有效预防内存扫描器。

上面的代码仍然不够完美,首先getSecret是函数形式、而且密码通过返回值传递,容易被分析破解;其次返回的密码的buffer内存需要调用者释放,代码不够整洁,而且调用者容易忘记。

宏改造

snippet3 
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 58 59 60 61 62 63 64 65 
#define kCipherKey ((uint8_t[]){'a','b','c','d','e','f','g','h','1','2','3','4','5','6','7','8'}) #define kSecret ((uint8_t[]){0x7e,0x77,0x64,0x3c,0xa7,0xd4,0x6d,0x46,0x29,0x8b,0xe3,0x23,0x9f,0x1a,0x5c,0xdb})  #define kAppSecret \ ({ \  size_t outLength = 0; \  char* buf = getSecret(outLength); \  [[NSString alloc] initWithBytes:buf \  length:outLength \  encoding:NSASCIIStringEncoding]; \ })  #define _CHK_CCSUCC(status, outLength) \  if ((status) != kCCSuccess) { \  outLength = 0; \  goto end; \ }  #define getSecret(outLength) \ ({ \  __label__ end; \  char* buf = NULL; \  \  CCCryptorRef cryptor = NULL; \  uint8_t iv[kCCBlockSizeAES128]; \  memset(iv, 0, kCCBlockSizeAES128); \  \  size_t bufsize = 0; \  size_t moved = 0; \  size_t total = 0; \  size_t inLength = sizeof(kSecret); \  \  _CHK_CCSUCC(CCCryptorCreate(kCCDecrypt, \  kCCAlgorithmAES128, \  kCCOptionPKCS7Padding, \  kCipherKey, sizeof(kCipherKey), \  iv, &cryptor), outLength); \  bufsize = CCCryptorGetOutputLength(cryptor, \  inLength, true); \  buf = (char*)alloca(bufsize); \  memset(buf, 0, bufsize); \  \  _CHK_CCSUCC(CCCryptorUpdate(cryptor, \  kSecret,inLength, \  buf, bufsize, &moved), \  outLength); \  total += moved; \  \  _CHK_CCSUCC(CCCryptorFinal(cryptor, \  buf+total, \  bufsize-total, &moved), \  outLength); \  total += moved; \  \  outLength = total; \ end: \  if (cryptor) { \  CCCryptorRelease(cryptor); \  } \  buf; \ })  int main(int argc, char * argv[]) {  NSLog(@"%@",kAppSecret); } 

这段代码稍微改造了一下,加入了一些必要的检测,让调用者更加简单,宏kAppSecret将密码包装成NSString对象。更重要的是,buf的内存不再是malloc到堆上,而是alloca到栈上(或者使用C99的变长数组),确切的说是调用者的栈,调用者不再需要手动释放内存;另外,因为kAppSecret是宏,没有有明确的入口,静态分析更加困难。

这里用了宏定义的两个技巧:

  • 带返回值的宏
1
2 3 4 
#define SOME_MACRO \ ({ \  expression; \ }) \ 

最后一个表达式的值就是宏的返回值,使用时更像函数的返回值。

  • 局部标签

局部标签用__label__定义。如果标签end没有__label__修饰,在同一个函数中多次使用kAppSecret将产生编译错误,因为宏展开后相当于定义了多个end标签,标签重复定义。

函数指针

在客户端访问Web Server的时候,Server往往要验证请求是否来自合法的客户端,而不是攻击者伪造的请求,这就需要客户端签名。例如OAuth的签名算法。如果自己定义签名算法,不希望别人知道签名的过程,就需要保护算法不被破解。例如签名算法是HMAC-SHA1(key,MD5(data))

signature1 
1
2 3 4 5 6 7 8 
- (NSString*) signatureData:(NSString*)data byKey:(NSString*)key {  unsigned char md[16];  CC_MD5(data.UTF8String, (CC_LONG)data.length, md);   char mac[CC_SHA1_DIGEST_LENGTH];  CCHmac(kCCHmacAlgSHA1, key.UTF8String, key.length, md, 16, mac);  return [self hexStringFromBytes:mac length:CC_SHA1_DIGEST_LENGTH]; } 

这段代码本身没有问题,但是对系统函数的直接调用导致代码容易被静态分析,用IDA、otool等静态分析工具可以很容易的知道这个函数的workflow,签名过程被轻易破解。为了防备静态分析,可以使用函数指针间接调用函数:

signature2 
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 
@interface SecurityService : NSObject  - (id) initWithMD5Function:(void*)md5 HMACFunction:(void*)hmac; - (NSString*) signatureData:(NSString*)data byKey:(NSString*)key;  @end  @implementation SecurityService {  void* _md5Funcation;  void* _hmacFuncation; }  - (id) initWithMD5Function:(void*)md5 HMACFunction:(void*)hmac {  if (self = [super init]) {  _md5Funcation = (void*)(unsigned long)((uint)&_md5Funcation^(uint)md5);  _hmacFuncation = (void*)(unsigned long)((uint)&_hmacFuncation^(uint)hmac);   }  return self; }   - (NSString*) signatureData:(NSString*)data byKey:(NSString*)key {  unsigned char md[16];  void* func = (void*)(unsigned long)((uint)&_md5Funcation ^ (uint)_md5Funcation);  ((unsigned char (*)(const void*, CC_LONG, unsigned char*))func)(data.UTF8String,  (CC_LONG)data.length,  md);   char mac[CC_SHA1_DIGEST_LENGTH];  func = (void*)(unsigned long)((uint)&_hmacFuncation ^ (uint)_hmacFuncation);  ((void(*)(CCHmacAlgorithm, const void*, size_t, const void*, size_t, void*))func)(kCCHmacAlgSHA1,  key.UTF8String,  key.length,  md,  16,  mac);  return [self hexStringFromBytes:mac length:CC_SHA1_DIGEST_LENGTH]; }  - (NSString*) hexStringFromBytes:(char*)bytes length:(NSUInteger)length {  NSMutableString *hexStr=[[NSMutableString alloc] initWithCapacity:2*length];   for(int i=0;i<length;i++) {  [hexStr appendFormat:@"%02x", bytes[i]&0xff];  }   return [NSString stringWithString:hexStr]; } @end  int main(int argc, char * argv[]) {  SecurityService* ss = [[SecurityService alloc] initWithMD5Function:CC_MD5 HMACFunction:CCHmac];  NSString* sign = [ss signatureData:@"1234" byKey:kAppSecret];  NSLog(@"%@",sign); } 

签名类初始化的时候,保存了HASH函数的地址值,执行签名的时,通过HASH函数的地址间接调用,这样静态分析工具分析到这里的时候,只能看到调用了某个地址,而不知道调用的具体函数,隐藏了真实目的。

这里不是直接将函数地址赋值给对象属性,而是用属性的地址与函数的地址做抑或运算。这样做主要有两个原因:

  • 直接赋值可能被编译器优化,编译器自动将使用该属性的地方替换成函数本身;

  • 类实例的创建有随机性,属性的内存地址也具有随机性,用属性地址加密函数地址,这样属性值在每次运行时都不一样;

在Android或其他平台还可以用dlsym来获取函数地址:

伪代码 
1
2 3 4 5 6 7 
char data[] = {0x32, 0x71, 0x0b, 0x48, 0xe3, 0xbc, 0x6a, 0x27, 0x8e, 0xca, 0x3b, 0x0e}; char sym[sizeof(data)/2 + 1] = {0}; for (int i=0; i<sizeof(data); i+=2) {  sym[i/2] = data[i] ^ data[i+1]; } // sym = "CC_MD5" void* md5 = dlsym(handle, sym); 

代码混淆

因为objc代码的动态性,编译器会在binary中留下类名、函数名等信息,这些信息是可以被class-dump-z等工具提取的,友好的命名让程序猿更方便,但同时也方便了破解者。对安全相关的重要模块类,可以故意混淆类名,让人不容易轻易联想到该的真实目的。比如把类名SecurityService改为FIFA。一些重要模块可以使用C/C++语言实现,编译器对C/C++并不会保留类名、方法名等信息。

使用混淆的名字对使用者很不方便,例如[[FIFA alloc] initWithMD5Function:CC_MD5 HMACFunction:CCHmac];这样的代码让人不知其意。可以用宏定义一个友好的名字来替代原来的类名#define SecurityService FIFA

动态调试

除了静态分析,破解者还可以使用gdb动态调试、Theos hook来分析代码,常用的系统加密函数、HASH函数都可能成为监控的对象,只要监控传递给他们的参数、调用栈就能轻松分析出密钥、算法等。所以使用系统的加密函数虽然节省开发时间、执行效率高,但并不是很安全,有些算法可能需要自己重写。

反调试

可以用ptrace等方法阻止gdb注入,但ptrace本身也可以被静态修改或hook。只好从多方面考虑,尽量提高安全性,比如检查binary签名是否匹配;检查手机是否越狱,越机做特殊处理等。参考http://blog.csdn.net/yiyaaixuexi/article/details/20286929

代码加密

类似UPX等加壳技术在iOS中无法使用,因为iOS堆、栈内存都没有执行权限,这也是jit技术无法在iOS中使用的原因(除非苹果自己或越狱系统)。

脚本

将算法用脚本实现,脚本被编译成bytecode后,app解释执行bytecode指令,可以有效的防止动态调试,因为hackers看到的将是一条条的指令在switch case中执行,就像把图片的像素逐个地放给别人看,当他看完全部的像素后也不一定知道整张图片是什么样子。当然用脚本方式会增大开发成本,对执行效率也有一定影响,需要开发者在安全、开发成本、性能三者之间找个平衡点。

最后

软件保护技术多种多样,比如构造花指令,甚至有硬件级的加密模块TPM(Trusted Platform Module)。总之没有绝对的安全,但危险显然也只是相对的,只要提高编码意识,注意防护就可以把风险降到最低。

你可能感兴趣的:(如何防止客户端被破解)