ios ZipperDown 漏洞

2018-05-17  本文已影响311人  天下林子

看了网易的新闻,得知很多app有ZipperDown安全隐患,故了解了一下。仅供参考,望指正。

漏洞的攻击原理。

漏洞原理

ZipperDown漏洞并非iOS平台自身问题,而是与Zip文件解压有关。iOS平台没有提供官方的unzipAPI函数,而是引用了第三方库来实现解压功能,由于现有的iOS App基本上采用SSZipArchive或Ziparchive来实现解压,因此漏洞是来自使用第三方Zip库解压Zip文件的过程中没有对Zip内文件名做校验导致的。如果文件名中含有“../”则可以实现目录的上一级跳转,从而实现应用内任意目录的跳转,进一步可以实现文件覆盖,如果把App的hotpatch文件覆盖替换了,可以达到执行黑客指定指令,从而按照黑客的意图实现任意应用内攻击。

这个漏洞不禁让易盾联想到不久前Android平台上的unZip解压文件漏洞,和这个漏洞几乎是完全一样,只是平台和第三方解压库不同而已。Android平台上的被称为unZip解压文件漏洞,网易云易盾安全检测平台已经可以实现扫描检测。

压缩文件是允许路径指向类似../A/../B这种格式的, UNIX下../代表这个文件夹的上一层。比如说/A/B/../C实际上指的是/A/C
有问题的解压库没有对这种../做过滤,也就是说可以往解压路径外的地方解压文件。这不是一个系统级的沙盒逃逸漏洞

很多App会把所谓的热更新补丁放在沙盒内的某个路径下,比如说我们叫Documents/A.js吧,如果热更新的传输过程有中间人攻击的问题或者app被通过某种方式打开恶意的压缩包, 攻击者就可以覆盖A.js的内容,这样下次app启动加载热更新补丁A.js时就会执行恶意代码。

SSZipArchive
我们在开发app的时候,有时会需要对文件进行压缩和解压的操作,比如百度网盘,这个时候我们就必须要用到一个第三方的开源库,SSZipArchive ,来对目标文件进行压缩和解压的操作。

// Unzip 解压  
      
/** 
 * @param          path    源文件 
 * @param   destination    目的文件 
 * @param      uniqueId    标记,用于区别多个解压操作 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示解压失败。 
 */  
+ (BOOL)unzipFileAtPath:(NSString *)path toDestination:(NSString *)destination  uniqueId:(NSString *)uniqueId;  
  
/** 
 * @param          path    源文件 
 * @param   destination    目的文件 
 * @param     overwrite    YES 会覆盖 destination 路径下的同名文件,NO 则不会。 
 * @param      password    需要输入密码的才能解压的压缩包 
 * @param         error    返回解压时遇到的错误信息 
 * @param      uniqueId    标记,用于区别多个解压操作 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示解压失败。 
 */  
+ (BOOL)unzipFileAtPath:(NSString *)path toDestination:(NSString *)destination overwrite:(BOOL)overwrite password:(NSString *)password error:(NSError **)error  uniqueId:(NSString *)uniqueId;  
  
/** 
 * @param          path    源文件 
 * @param   destination    目的文件 
 * @param      delegate    设置代理 
 * @param      uniqueId    标记,用于区别多个解压操作 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示解压失败。 
 */  
+ (BOOL)unzipFileAtPath:(NSString *)path toDestination:(NSString *)destination delegate:(id<SSZipArchiveDelegate>)delegate  uniqueId:(NSString *)uniqueId;  
  
/** 
 * @param          path    源文件 
 * @param   destination    目的文件 
 * @param     overwrite    YES 会覆盖 destination 路径下的同名文件,NO 则不会。 
 * @param      password    需要输入密码的才能解压的压缩包 
 * @param         error    返回解压时遇到的错误信息 
 * @param      delegate    设置代理 
 * @param      uniqueId    标记,用于区别多个解压操作 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示解压失败。 
 */  
+ (BOOL)unzipFileAtPath:(NSString *)path toDestination:(NSString *)destination overwrite:(BOOL)overwrite password:(NSString *)password error:(NSError **)error delegate:(id<SSZipArchiveDelegate>)delegate uniqueId:(NSString *)uniqueId; 

/*
 *  解压
 */
+ (BOOL)unzipFileAtPath:(NSString *)path
          toDestination:(NSString *)destination
     preserveAttributes:(BOOL)preserveAttributes
              overwrite:(BOOL)overwrite
         nestedZipLevel:(NSInteger)nestedZipLevel
               password:(nullable NSString *)password
                  error:(NSError **)error
               delegate:(nullable id<SSZipArchiveDelegate>)delegate
        progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
      completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler;

// Zip 压缩  
/** 
 * @param       path    目的路径(格式:~/xxx.zip 结尾的路径) 
 * @param  filenames    要压缩的文件路径 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示压缩失败。 
 */  
+ (BOOL)createZipFileAtPath:(NSString *)path withFilesAtPaths:(NSArray *)filenames;  
  
/** 
 * @param       path    目的路径(格式:~/xxx.zip 结尾的路径) 
 * @param  filenames    要压缩的文件目录路径 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示压缩失败。 
 */  
+ (BOOL)createZipFileAtPath:(NSString *)path withContentsOfDirectory:(NSString *)directoryPath;  
  
/** 
 * 初始化压缩对象 
 * 
 * @param  path    目的路径(格式:~/xxx.zip 结尾的路径) 
 * 
 * @return 初始化后的对像 
 */  
- (id)initWithPath:(NSString *)path;  
  
/** 
 *  打开压缩对象 
 * @return 返回 YES 表示成功,返回 NO 表示失败。 
 */  
- (BOOL)open;  
  
/** 
 * 添加要压缩的文件的路径 
 * 
 * @param  path    文件路径 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示失败。 
 */  
- (BOOL)writeFile:(NSString *)path;  
  
/** 
 * 向此路径的文件里写入数据 
 * 
 * @param      data    要写入的数据 
 * @param  filename    文件路径 
 * 
 * @return 返回 YES 表示成功,返回 NO 表示失败。 
 */  
- (BOOL)writeData:(NSData *)data filename:(NSString *)filename;  
  
/** 
 *  关闭压缩对象 
 * @return 返回 YES 表示成功,返回 NO 表示失败。 
 */  
- (BOOL)close; 

@optional  
  
//将要解压  
- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo;  
//解压完成  
- (void)zipArchiveDidUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo unzippedPath:(NSString *)unzippedPat uniqueId:(NSString *)uniqueId;  
//将要解压  
- (void)zipArchiveWillUnzipFileAtIndex:(NSInteger)fileIndex totalFiles:(NSInteger)totalFiles archivePath:(NSString *)archivePath fileInfo:(unz_file_info)fileInfo;  
//解压完成  
- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex totalFiles:(NSInteger)totalFiles archivePath:(NSString *)archivePath fileInfo:(unz_file_info)fileInfo;  
  
@end 

当对文件进行解压的时候,如果文件名包含了../,则可以实现目录上一级跳转,从而实现应用内任意目录的跳转,进一步可以实现文件覆盖。
例如: 有一个文件名为wzg/../wwww.zip,在对该文件下载之后进行解压,如果正常情况下,为下图的第一种情况,解压的test.txt文件会在C文件位置解压出来,如果出现不正确的路径,如下图的第二种情况,解压的test.txt文件会在C的上面一级文件夹路径A中解压出来。

image.png

而在三方SSZipArchive中,底层实现如下:

#pragma mark - ================解压===unzipFileAtPath=============================
+ (BOOL)unzipFileAtPath:(NSString *)path
          toDestination:(NSString *)destination
     preserveAttributes:(BOOL)preserveAttributes
              overwrite:(BOOL)overwrite
         nestedZipLevel:(NSInteger)nestedZipLevel
               password:(nullable NSString *)password
                  error:(NSError **)error
               delegate:(nullable id<SSZipArchiveDelegate>)delegate
        progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
      completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler
{
//==============核心代码=================
  unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0);
            filename[fileInfo.size_filename] = '\0';
            
            BOOL fileIsSymbolicLink = _fileIsSymbolicLink(&fileInfo);
        #pragma mark ------------------核查文件的路径是否包含不规范字符---------------------------------
            NSString * strPath = [SSZipArchive _filenameStringWithCString:filename size:fileInfo.size_filename];
            if ([strPath hasPrefix:@"__MACOSX/"]) {
                // ignoring resource forks: https://superuser.com/questions/104500/what-is-macosx-folder
                unzCloseCurrentFile(zip);
                ret = unzGoToNextFile(zip);
                continue;
            }
            if (!strPath.length) {
                // if filename data is unsalvageable, we default to currentFileNumber
                strPath = @(currentFileNumber).stringValue;
            }

            // Check if it contains directory
            BOOL isDirectory = NO;
            if (filename[fileInfo.size_filename-1] == '/' || filename[fileInfo.size_filename-1] == '\\') {
                isDirectory = YES;
            }
            free(filename);
            
            // Contains a path
            if ([strPath rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"/\\"]].location != NSNotFound) {
                strPath = [strPath stringByReplacingOccurrencesOfString:@"\\" withString:@"/"];
            }
            
            NSString *fullPath = [destination stringByAppendingPathComponent:strPath];
            NSError *err = nil;
            NSDictionary *directoryAttr;
            if (preserveAttributes) {
                NSDate *modDate = [[self class] _dateWithMSDOSFormat:(UInt32)fileInfo.dos_date];
                directoryAttr = @{NSFileCreationDate: modDate, NSFileModificationDate: modDate};
                [directoriesModificationDates addObject: @{@"path": fullPath, @"modDate": modDate}];
            }
#pragma mark ----------------解压路径---------------------------------
            //如果是一个沙盒路径, 就创建这个路径,将解压的 东西放到这个路径下
            //如果不是则创建一个路径,
            /*
             stringByDeletingLastPathComponent一个新的字符串由来自接收者的组件删除最后一个路径,以及最终的路径分隔符。
             Receiver’s String Value      Resulting String
             “/tmp/scratch.tiff”           “/tmp”
             “/tmp/lock/”                   “/tmp”
             “/tmp/”                        “/”
             “/tmp”                         “/”
             “/”                            “/”
             “scratch.tiff”                 “” (an empty string)
             
             */
            NSLog(@"-------dir-------%@", fullPath);
            if (isDirectory) {
                [fileManager createDirectoryAtPath:fullPath withIntermediateDirectories:YES attributes:directoryAttr error:&err];
            } else {
                [fileManager createDirectoryAtPath:fullPath.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:directoryAttr error:&err];
            }
            NSLog(@"-------dir--2222-----%@", fullPath.stringByDeletingLastPathComponent);
            if (nil != err) {
                if ([err.domain isEqualToString:NSCocoaErrorDomain] &&
                    err.code == 640) {
                    unzippingError = err;
                    unzCloseCurrentFile(zip);
                    success = NO;
                    break;
                }
                NSLog(@"[SSZipArchive] Error: %@", err.localizedDescription);
            }
            
            if ([fileManager fileExistsAtPath:fullPath] && !isDirectory && !overwrite) {
                //FIXME: couldBe CRC Check?
                unzCloseCurrentFile(zip);
                ret = unzGoToNextFile(zip);
                continue;
            }
            
            if (!fileIsSymbolicLink) {
                // ensure we are not creating stale file entries
                //确保我们没有创建的文件条目
                int readBytes = unzReadCurrentFile(zip, buffer, 4096);
                if (readBytes >= 0) {
                    FILE *fp = fopen(fullPath.fileSystemRepresentation, "wb");
                    while (fp) {
                        if (readBytes > 0) {
                            if (0 == fwrite(buffer, readBytes, 1, fp)) {
                                if (ferror(fp)) {
                                    NSString *message = [NSString stringWithFormat:@"Failed to write file (check your free space)"];
                                    NSLog(@"[SSZipArchive] %@", message);
                                    success = NO;
                                    unzippingError = [NSError errorWithDomain:@"SSZipArchiveErrorDomain" code:SSZipArchiveErrorCodeFailedToWriteFile userInfo:@{NSLocalizedDescriptionKey: message}];
                                    break;
                                }
                            }
                        } else {
                            break;
                        }
                        readBytes = unzReadCurrentFile(zip, buffer, 4096);
                        if (readBytes < 0) {
                            // Let's assume error Z_DATA_ERROR is caused by an invalid password
                            // Let's assume other errors are caused by Content Not Readable
                            success = NO;
                        }
                    }
                    
                    if (fp) {
                        //关闭文件
                        fclose(fp);
                        
                        if (nestedZipLevel
                            && [fullPath.pathExtension.lowercaseString isEqualToString:@"zip"]
                            && [self unzipFileAtPath:fullPath
                                       toDestination:fullPath.stringByDeletingLastPathComponent
                                  preserveAttributes:preserveAttributes
                                           overwrite:overwrite
                                      nestedZipLevel:nestedZipLevel - 1
                                            password:password
                                               error:nil
                                            delegate:nil
                                     progressHandler:nil
                                   completionHandler:nil]) {
                            [directoriesModificationDates removeLastObject];
                            [[NSFileManager defaultManager] removeItemAtPath:fullPath error:nil];
                        } else if (preserveAttributes) {
                            
                            // Set the original datetime property
                            if (fileInfo.dos_date != 0) {
                                NSDate *orgDate = [[self class] _dateWithMSDOSFormat:(UInt32)fileInfo.dos_date];
                                NSDictionary *attr = @{NSFileModificationDate: orgDate};
                                
                                if (attr) {
                                    if (![fileManager setAttributes:attr ofItemAtPath:fullPath error:nil]) {
                                        // Can't set attributes
                                        NSLog(@"[SSZipArchive] Failed to set attributes - whilst setting modification date");
                                    }
                                }
                            }
                            
                            // Set the original permissions on the file (+read/write to solve #293)
                            uLong permissions = fileInfo.external_fa >> 16 | 0b110000000;
                            if (permissions != 0) {
                                // Store it into a NSNumber
                                NSNumber *permissionsValue = @(permissions);
                                
                                // Retrieve any existing attributes
                                NSMutableDictionary *attrs = [[NSMutableDictionary alloc] initWithDictionary:[fileManager attributesOfItemAtPath:fullPath error:nil]];
                                
                                // Set the value in the attributes dict
                                attrs[NSFilePosixPermissions] = permissionsValue;
                                
                                // Update attributes
                                if (![fileManager setAttributes:attrs ofItemAtPath:fullPath error:nil]) {
                                    // Unable to set the permissions attribute
                                    NSLog(@"[SSZipArchive] Failed to set attributes - whilst setting permissions");
                                }
                            }
                        }
                    }
                    else
                    {
                        // if we couldn't open file descriptor we can validate global errno to see the reason
                        if (errno == ENOSPC) {
                            NSError *enospcError = [NSError errorWithDomain:NSPOSIXErrorDomain
                                                                       code:ENOSPC
                                                                   userInfo:nil];
                            unzippingError = enospcError;
                            unzCloseCurrentFile(zip);
                            success = NO;
                            break;
                        }
                    }
                } else {
                    // Let's assume error Z_DATA_ERROR is caused by an invalid password
                    // Let's assume other errors are caused by Content Not Readable
                    success = NO;
                    break;
                }
            }
            else
            {
                // Assemble the path for the symbolic link
                NSMutableString *destinationPath = [NSMutableString string];
                int bytesRead = 0;
                while ((bytesRead = unzReadCurrentFile(zip, buffer, 4096)) > 0)
                {
                    buffer[bytesRead] = 0;
                    [destinationPath appendString:@((const char *)buffer)];
                }
                if (bytesRead < 0) {
                    // Let's assume error Z_DATA_ERROR is caused by an invalid password
                    // Let's assume other errors are caused by Content Not Readable
                    success = NO;
                    break;
                }

分析解压代码,可以知道,在进行解压的时候,三方库是有对解压的文件夹名字进行做路径判断和处理,

                fullPath                 处理后的fullPath 
             “/tmp/scratch.tiff”           “/tmp”
             “/tmp/lock/”                   “/tmp”
             “/tmp/”                        “/”
             “/tmp”                         “/”
             “/”                            “/”
             “scratch.tiff”                 “” (an empty string)

但是三方库中并没有在解压的方法中处理出现../这种情况,
最完整的解决方案是对SSZipArchive库进行修补,在解压函数:

js的方法调用规则是必须对已经存在的对象调用已经存在的方法,构建对象,就是在调用方法前使用require函数为每一个类在js中构建同名全局对象,源码如下:

var _require = function(clsName) {
    if (!global[clsName]) {
      global[clsName] = {
        __clsName: clsName
      }
    } 
    return global[clsName]
  }

  global.require = function(clsNames) {
    var lastRequire
    clsNames.split(',').forEach(function(clsName) {
      lastRequire = _require(clsName.trim())
    })
    return lastRequire
  }

js构建函数,为了避免内存消耗,只定义了一个元函数,在元函数中将类名、方法名以及参数传递给OC,js脚本代码在交由JavaScriptCore执行前,是先经过转换的,所有的方法调用都被转换成了调用__c函数,js源码的这样转换是通过正则匹配替换的,核心代码如下:

NSString *formatedScript = [NSString stringWithFormat:@";(function(){try{\n%@\n}catch(e){_OC_catch(e.message, e.stack)}})();",[_regex stringByReplacingMatchesInString:script options:0 range:NSMakeRange(0, script.length) withTemplate:_replaceStr]];

替换后的JS代码 ps:所有的方法调用都被替换成了__c函数调用并将方法名作为参数传入:

;(function(){try{
require('UIColor,UIImage');
defineClass('CustomCell', {
    configWithModel: function(model) {
        self.__c("headView")().__c("layer")().__c("setCornerRadius")(5.0);
        self.__c("headView")().__c("layer")().__c("setBorderColor")(UIColor.__c("darkGrayColor")().__c("CGColor")());
        self.__c("headView")().__c("layer")().__c("setBorderWidth")(1.0);
        self.__c("headView")().__c("layer")().__c("setMasksToBounds")(YES);
        self.__c("headView")().__c("setImage")(UIImage.__c("imageNamed")(model.__c("imgPath")()));

        self.__c("contentLabel")().__c("setText")(model.__c("content")());
        self.__c("contentLabel")().__c("setNumberOfLines")(0);
    },
});
JS将消息传递给OC,内部实现是根据实例方法或者类方法调用了_OC_callI和_OC_callC中的其中一个,而这两个函数在初始化JPEnige的时候就已经注册到JS上下文了,这是JavaScriptCore的接口,在JS上下文中创建JS函数。当函数被调用,会将消息传递给OC端,同时将参数传递给OC,OC执行相应的block,最后将返回值回传JS,其实js传递消息给oc,是借助于JavaScriptCore。OC从JS端接收了消息,需要调用指定方法。JSPatch在处理的时候是通过NSInvocation来调用的,这是因为:JS传过来的参数类型需要转换成OC相应的类型,而NSInvocation很方便从方法签名中获取方法参数类型。同时,也能根据返回值类型取出返回值。

ps:iOS中可以直接调用 某个对象的消息 方式有2种,一种是performSelector:withObject:另一种是NSInvocation,NSInvocation也是一种消息调用的方法,并且它的参数没有限制,可以处理参数、返回值等相对复杂的操作。
JSPatch通过下发JS脚本文件对app进行修复或更新,JS脚本的权限是很大的,如果在下发传输过程中文件被第三方截获,可以修改了脚本内容,故在使用时需对脚本文件进行加密处理。对脚本文件加密主要有以下方案:
a、可以使用对称加密,服务器端和客户端保存一把相同的私钥,下发脚本文件前先对文件进行加密,客户端拿到脚本文件后用相同的私钥解密。这种方案弊端很明显,密钥保存在客户端,一旦客户端被破解,密钥就泄露了。
b、https传输。不过需要购买证书,部署服务器,这种方案也比较安全可靠。
c、RSA签名验证。通过RSA非对称加密,此时需要服务器端,对要下发的脚本文件计算MD5值,用服务器私钥对MD5只进行加密,将脚本文件和加密后的MD5值下发给客户端,而客户端,需要用服务器端公钥解密加密过的MD5值,对接受的脚本文件计算MD5值,将解密出来的MD5与新计算出来的MD5进行比对校验,如果校验通过,则表明脚本在传输过程中没有被篡改。

如何来检测ZipperDown漏洞?
通过指纹匹配可以获取疑似受影响的应用列表。但该漏洞形态灵活、变种类型多样,指纹匹配的漏报率很高。所以我们建议通过人工分析的方式确认漏洞是否存在。

ZipperDown漏洞如何触发?
ZipperDown漏洞攻击场景与受影响应用业务场景相关。常见攻击场景包括:在不安全网络环境下使用受影响应用、在攻击者诱导下使用某些应用功能等。

对漏洞进行安全防范

针对 iOS 应用的 ZipperDown 漏洞,对IOS 应用可以进行一下的几点防守策略。

参考1
参考2
参考3
参考4

上一篇下一篇

猜你喜欢

热点阅读