脚本的安全问题初探
在linux常用的脚本很多,例如shell几乎是linux必备的脚本。脚本也是一种可执行文件,因此,它也面临这安全类的问题。脚本从它产生的情况来看,可以分为两类:
一类是静态的,就是脚本是以前写好的,而且运行时只需要执行该脚本。以后不会去修改它。这是很常见的。很多linux服务器在部署的时候,这些脚本就会被部署。
另一类是动态的,也就是脚本本省并不是部署好的,而是其他程序在运行时动态生成的,然后保存成临时脚本的形式,生成程序或其他程序来执行它。这种技术也是普遍存在的,可能编程的人认为这种方式实现某些问题比较简单,从而采取了这种设计。事实上,这种脚本会带来严重的安全问题,而且难以解决。
对于第一类脚本,解决方法比较多。例如加密,签名等方法都可以增加安全性。例如,使用非对称加密RSA私钥给静态的脚本进行签名,服务器上放置公钥,脚本在运行时,使用公钥解密签名,进行验证。这种方式是足够安全的,而且也不复杂。只需要修改一下脚本解释器,在执行脚本的时候添加验证逻辑就行。当然,这种情况只限于使用了静态脚本的服务器上。这种方式下,任何脚本都会被要求强制进行签名校验。(前提当然是脚本将解释器不会被替换,这也可以使用签名验证的方式在二进制可执行文件上,这里就不讨论这方面的问题,配合签名和lsm模块,可以实现二进制可执行文件的签名校验)。
然而如果服务器上的程序使用了动态脚本技术,很显然程序生成的脚本没有被签名,从而执行也会失败。因此,动态脚本需要重新考虑。
动态脚本本质上的问题就是动态生成脚本身份的确认问题,也就是如何确认这个脚本是由程序动态生成的。关于身份确认的问题,肯定离不开签名,第一个解决方案也是基于此的:如果程序生成动态脚本的时候,同时生成一个数字签名,这样子脚本解释器执行的时候验证数字签名就行了。为了防止入侵者自己写一个脚本,然后同样生成一个数字签名而来欺骗脚本解释器,因此这里的数字签名算法必须被保护,也就是入侵者不能使用该方法同样给其他的脚本进行签名。想到这里,大家肯定都想,我改写一下某个数字签名算法,比如sha1,然后将改写的部分进行保护。这样安全性就依赖改写的sha1算法被保护的程度了。考虑这样一个场景,某cgi程序a和bash使用了同样的改写的sha1算法计算数字签名,这样它们就可以配合工作。因此a和bash中都有着改写的sha1算法的实现。这问题就来了,逆向工程使得它们是极其的不安全。a或者bash被逆向后,就可以分析出改写的sha1算法的实现。因此,必须要增加逆向难度,例如使用模糊技术,加壳技术使得逆向过程难度增大。但并不意味着逆向不可能。或许你们公司使用先进的加壳技术以及对代码进行复杂的模糊处理使得逆向实际上变得不可能也是可以的。笔者为了测试一下模糊代码对反编译确实能造成多大的困难,便做了一个很简单的测试。也就是对sha1算法做了一个简单的处理,按照一个简单的规则改了下缓冲区中的数据,然后在计算sha1.主要是看代码反编译后的样子。程序源代码如下:
#include <openssl/sha.h> #include <assert.h> #include <stdio.h> #include <sys/stat.h> #include <stdlib.h> #include <string.h> #include <time.h> #include "variant_sha1.h" #define SHA_SWAP_BUFF_LEN 3 //sha1计算过程中的临时变量的长度 #define VARIANT_CONST 10 //变异算法中使用的常量 /** * 获取文件大小 * @param filename 是输入文件 * @return -1表示异常,>0表示文件大小 */ static int get_file_size(const char* filename) { struct stat buf; if (filename == NULL) return -1; if (stat(filename, &buf)<0) return -1; return buf.st_size; } /* * 用于对data_buff数据进行变异处理,为了增加反编译难度,该函数内大量 * 使用了goto来增加流程模糊的效果.该函数不符合checklist,目的是保护代码 * @data_buff 缓冲区指针 * @length 缓冲区长度 由调用这保证前置条件 data_buff != NULL 以及 length >=0 */ void static variant_buff(char* data_buff, int length) { assert(data_buff); assert(length >= 0); int gotoflag = length & (~length) + 1; int location = gotoflag - (gotoflag & (~gotoflag)); int i = 0; int j = 0; int k =0; begin: if (length < 0) { goto length_error; }else{ srand((unsigned)time(NULL)); gotoflag = rand() % (VARIANT_CONST * VARIANT_CONST + length % (rand() % VARIANT_CONST + 1) + 1); if (gotoflag > 0) { gotoflag = 0; goto handle; }else{ goto begin; } } handle: if ((length & 1) == 0) { srand((unsigned)time(NULL)); gotoflag = rand() % (VARIANT_CONST * VARIANT_CONST + length % (rand() % VARIANT_CONST + 1) + 1); if (gotoflag > 0) { gotoflag = 0; goto even_handle; }else{ goto begin; } }else{ srand((unsigned)time(NULL)); gotoflag = (rand() + SHA_SWAP_BUFF_LEN) % (VARIANT_CONST * VARIANT_CONST + length % (rand() % VARIANT_CONST + 1) + 1); if (gotoflag > 0) { gotoflag = 0; goto odd_handle; }else{ goto begin; } } //实际变异处理的代码 even_handle: if (length == 0) { goto length_error; } i = (length -1) >> 1; j = i / VARIANT_CONST; k = i - j * VARIANT_CONST; i = j; while (k > 0) { *(data_buff + k) = (*(data_buff + k) + k); j = i / VARIANT_CONST; k = i - j * VARIANT_CONST; i = j; } if (k == 0) { *(data_buff + k) = (*(data_buff + k) + VARIANT_CONST); } length = length >> 1; if ((length & 1) == 0) { length += 1; } goto begin; odd_handle: i = (length >> 1) - 1; j = i / (VARIANT_CONST >> 1); k = i - j * (VARIANT_CONST >> 1); i = j; while (k > 0) { //printf("k=%d\n",k); *(data_buff + k) = (*(data_buff + k) + k); j = i / (VARIANT_CONST >> 1); k = i - j * (VARIANT_CONST >> 1); i = j; } if (k == 0) { *(data_buff + k) = (*(data_buff + k) + VARIANT_CONST); } length_error: if (gotoflag >0) { goto begin; }else{ return; } }
/* * 生成变异后的sha1摘要 * @filename 待计算sha1的文件名称 * @sha1buff 存放结果缓冲区 长度是41 * @return 0表示成功,<0表示失败 */ int variant_sha1(const char* filename, char* sha1buff) { assert(sha1buff); SHA_CTX sha_ctx; int file_len = 0; unsigned char* data_buff = NULL; unsigned char sha[SHA_DIGEST_LENGTH+1] = {0}; char tmp[SHA_SWAP_BUFF_LEN] = {'\0'}; int i = 0; int retval = 0; FILE* fd = NULL; if (filename == NULL) { retval = FILE_NAME_ERROR; goto file_name_error; } fd = fopen(filename, "r"); if (fd == NULL) { retval = FILE_OPEN_ERROR; goto file_name_error; } file_len = get_file_size(filename); if (file_len <= 0) { retval = FILE_LEN_ERROR; goto file_len_error; } if ((data_buff = (unsigned char *)calloc(file_len, sizeof(unsigned char))) == NULL) { retval = MALLOC_ERROR; goto file_len_error; } if (SHA1_Init(&sha_ctx) != 1) { retval = SHA1_ERROR; goto sha1_error; } if (file_len == fread(data_buff, sizeof(unsigned char), file_len,fd)) { //printf("-========>data_buff:%s\n",data_buff); variant_buff(data_buff, file_len); if (SHA1_Update(&sha_ctx, data_buff, file_len) != 1) { retval = SHA1_ERROR; goto sha1_error; } }else { retval = FREAD_ERROR; goto sha1_error; } if (SHA1_Final(sha, &sha_ctx) != 1) { retval = SHA1_ERROR; goto sha1_error; } //将sha转换成字符到sha1buff for (i = 0;i < SHA_DIGEST_LENGTH ; i++ ) { sprintf(tmp, "%2.2x", sha[i]); strcat(sha1buff, tmp);//sha1buff长度是固定的41,该循环不会导致缓冲区溢出 } sha1_error: free(data_buff); data_buff = 0; file_len_error: fclose(fd); fd = 0; file_name_error: return retval; } int main() { int v = 0; char sha1buff[41] = {0}; v = variant_sha1("123.txt", sha1buff); printf("%s", sha1buff); }
这段代码很简单,就是在SHA1_Init之后改变一下data_buff,在函数varinant_buff中有许多goto,将一个很简单的流程写的乱起八糟,目的就是想看反汇编和反编译的结果。从反汇编结果来看,代码流程确实显得乱,但也不是不可分析。不过可以明显的感觉到源代码中代码模糊处理后在汇编代码里看起来,更加的混乱。在这里就不贴汇编结果了,有兴趣的可以自己使用ida反汇编一下。再看一下反编译的代码,这个比较有用,虽然反编译后的程序大部分几乎都是不能编译运行的,但是可以看出程序原来的部分面貌。这里只给出variant_buff反编译后的代码,可以看出反编译质量还是很高的。
//----- (080487B9) -------------------------------------------------------- int __cdecl variant_buff(int a1, signed int a2) { unsigned int v2; // eax@6 int v3; // ebx@6 unsigned int v4; // eax@8 int v5; // ebx@8 unsigned int v6; // eax@11 signed int v7; // ebx@11 int result; // eax@24 int v9; // [sp+2Ch] [bp-1Ch]@0 int v10; // [sp+34h] [bp-14h]@12 signed int v11; // [sp+34h] [bp-14h]@13 int v12; // [sp+3Ch] [bp-Ch]@12 int v13; // [sp+3Ch] [bp-Ch]@13 if ( !a1 ) __assert_fail("data_buff", "variant_sha1.c", 0x35u, "variant_buff"); if ( a2 < 0 ) __assert_fail("length >= 0", "variant_sha1.c", 0x36u, "variant_buff"); while ( 1 ) { while ( 1 ) { do { if ( a2 < 0 ) goto LABEL_24; v2 = time(0); srand(v2); v3 = rand(); v9 = v3 % (a2 % (rand() % 10 + 1) + 101); } while ( v9 <= 0 ); if ( a2 & 1 ) break; v4 = time(0); srand(v4); v5 = rand(); v9 = v5 % (a2 % (rand() % 10 + 1) + 101); if ( v9 > 0 ) { v9 = 0; if ( !a2 ) goto LABEL_24; v13 = ((a2 - 1) >> 1) + -10 * (((signed int)((unsigned __int64)(1717986919LL * ((a2 - 1) >> 1)) >> 32) >> 2) - ((a2 - 1) >> 32)); v11 = ((signed int)((unsigned __int64)(1717986919LL *((a2 - 1) >> 1)) >> 32) >> 2) - ((a2 - 1) >> 32); while ( v13 > 0 ) { *(_BYTE *)(a1 + v13) += v13; v13 = v11 % 10; v11 /= 10; } if ( !v13 ) *(_BYTE *)a1 += 10; a2 >>= 1; if ( !(a2 & 1) ) ++a2; } } v6 = time(0); srand(v6); v7 = rand() + 3; v9 = v7 % (a2 % (rand() % 10 + 1) + 101); if ( v9 > 0 ) { v9 = 0; v12 = ((a2 >> 1) - 1) % 5; v10 = ((a2 >> 1) - 1) / 5; while ( v12 > 0 ) { printf("k=%d\n", v12); *(_BYTE *)(a1 + v12) += v12; v12 = v10 % 5; v10 /= 5; } if ( !v12 ) *(_BYTE *)a1 += 10; LABEL_24: result = v9; if ( v9 <= 0 ) return result; } } }
虽然反编译的函数的参数原型是错误的,但是,反编译的质量很高,提供了大量参考信息。而且很多都是有效信息。不过实际模糊处理要复杂的多,使用复杂的变量,名称模糊和流程模糊技术使得反汇编和反编译代码难以理解是可以做到的。配合加壳技术,使得程序变得安全。虽然这一切都很完美,似乎能达到要求,但是,其实第一种方案是不安全的,也是不能采用的。原因是破解并不一定需要理解你代码。
入侵者拷贝走cgi程序a或者bash之后,在自己的机器上调试执行(指令级别),只需要寻找到函数调用的入口,然后在入口处传入自己的参数就可以了,他无需理解你内部复杂的过程,程序返回的结果将会是他期望的签名。这个签名就是它入侵脚本合法的凭据了,上传到服务器后,便不会被发现。没想到,破解如此容易,第一个方案是不可行的。
看到这里,不要灰心,我们需要思考其他的办法。第一种方案失败的原因在于计算签名的过程绑定在可执行文件中,一旦可执行文件被拷贝走,入侵者就开始分析,动态调试便可以帮助他达到目的,找到计算改变的sha1的入口,然后就是我们的机器被入侵了,还不能被发现。因此我们需要将需要保护的代码段(算法)和可执行文件进行分离。其实这个问题类似于不能将加解密的密钥放置在程序内部一样。第二种方法便是从这个结论出发的。
将保护的代码部分和程序进行分离,因此不需要将保护的代码进行模糊等复杂处理,因此可以使用des或者aes加密标准的sha1来当作签名。因此,问题在于需要提供加密和解密服务,而且密钥要安全,与可执行文件分离。提供加解密服务可以使用内核模块来提供,使用通信的方式为可执行文件提供服务。问题的关键便转换到密钥的保护问题上。
只要阻止来自用户层的对密钥的访问,那么密钥就安全了。因此密钥需要放在一个特别的目录下,用户不能访问该文件夹,而只有内核可以访问它。实现这点可以在lsm的inode访问的钩子上进行阻止,来自用户的访问通通给拒绝掉。当然如果用户拆下了硬盘,然后在别的设备上去读取,那肯定没有问题,这种方式还是可以获取密钥的。 如果竞争对手或者入侵者买下你们设备,拆下硬盘,获取密钥,进行其他的操作...为防止这种行为,每台设备使用的密钥应该随机生成,随机生成的密钥不影响程序的执行,这便能防止上面的攻击方法。
小结一下,这种方式保护动态脚本有两个前提条件,首先是保证脚本解释器程序的正确性,第二点是需要确认所有的产生动态脚本的代码。动态脚本生成后,将自己的数字签名发给内核模块,内核使用密钥加密后返回给应用程序,应用程序将数字签名保存。因此,需要修改使用动态脚本的应用程序的部分代码。
看到这里,精明的你可能发现了,这种方法不行,因为你需要暴露api给应用程序来调用内核的加密服务。如果可能你暴露的api很简单,如果攻击者发现了你这个接口,那么我们的这么多努力不就全部浪费了嘛。确实,因此,在调用内核的加解密服务的api里,需要白名单机制来保证请求的合法性。将会生成动态脚本的程序(包含二进制可执行程序以及脚本)加入到白名单,只有白名单里的程序才能成功请求加解密服务。
为解决动态脚本的安全问题,真是很费事。因此建议代码中应该少用这种技术。这个需求都是用来解决遗留代码的安全性的,新代码强烈建议不要使用动态脚本技术。动态脚本在本质上难以和攻击脚本进行区分,特别是你的动态脚本执行的动作类似于恶意脚本的时候,会使得系统安全能力急剧退化。
本文简要分析了动态脚本的安全性问题,给出了两个方案,并分析了第一种方案的漏洞。并逐步完善了第二种方案。仅供大家参考。