领悟到 NSCoding 是一个坑,Apple 花了10年时间

2,845 阅读6分钟
原文链接: github.com

本文主要阐述以下观点,如果你早已知晓,请忽略本条内容:

  • Apple 在 2008年7月11日 推出的 iOS2 里,带来了 NSKeyedArchiver、NSKeyedUnarchiver,2018年9月10日发布的 iOS12 几乎废弃了 NSKeyedArchiver、NSKeyedUnarchiver 中所有的方法,甚至包括 init 方法。Apple花了10年时间,发现 NSCoding 是一个坑。
  • iOS6 推出的 NSSecureCoding 是高配版本的 NSCoding,NSCoding 并不适合你用,请及早弃坑。而这不是你的问题,是 NSCoder、NSKeyedArchiver、NSKeyedUnarchiver 内部实现有问题。iOS6 修复了 NSCoder 的问题,iOS12 修复的是 NSKeyedArchiver、NSKeyedUnarchiver。
  • UIKit 出厂时还在用 NSCoding,Foundation 出厂时,标配 NSSecureCoding。
  • Foundation 中任何类都可以遵循 NSCoding,但是 NSObject 出厂时,并没有遵循。所以实现 -initWithCoder: 时,不能总是调用父类的 [super initWithCoder:],有时要调 [super init]
  • Nib 与 Storyboard 方式加载的 UIViewController ,是通过 -initWithNibName:bundle: 来声明文件路径,-initWithCoder: 与 NSKeyedUnarchiver 来加载的。了解这一点,有助于理解 UIViewController 生命周期。
  • 当一个对象被初始化时,init 方法可能并不会被执行,可能是通过执行 -initWithCoder: 初始化,所以只在 init 进行初始化,可能会丢失数据、属性。 -initWithCoder: 常常被忽略。

另外,本文示例代码基于 Objective-C, 对于 Swift 开发者,可以查看新出的 Codable 文档,本文请谨慎浏览。

NSCoding 简介

NSCoding 是为了 NSData 与对象转换而设计的协议。命名也是进行时态,很形象。

任何想要与 NSData 自由转换身份的对象,都需要遵从 NSCoding。当时 Apple 设计 NSCoding 的时候,可能没想到自己埋了很多坑。

坑被发现后,在 iOS6 推出 NSSecureCoding,解决 NSCoder 解码异常问题

关于 NSCoder 里的坑,Apple 官网讲得很清楚: Documentation-Foundation-Archives and Serialization-NSSecureCoding,在此不做赘述。

关于 NSSecureCoding 用法,下面是 AFNetworking 的两个使用场景:

场景一:常规操作

#pragma mark - NSSecureCoding

+ (BOOL)supportsSecureCoding {
   return YES;
}

- (instancetype)initWithCoder:(NSCoder *)decoder {
   self = [self init];
   if (!self) {
       return nil;
   }

   self.acceptableStatusCodes = [decoder decodeObjectOfClass:[NSIndexSet class] forKey:NSStringFromSelector(@selector(acceptableStatusCodes))];
   self.acceptableContentTypes = [decoder decodeObjectOfClass:[NSIndexSet class] forKey:NSStringFromSelector(@selector(acceptableContentTypes))];

   return self;
}

- (void)encodeWithCoder:(NSCoder *)coder {
   [coder encodeObject:self.acceptableStatusCodes forKey:NSStringFromSelector(@selector(acceptableStatusCodes))];
   [coder encodeObject:self.acceptableContentTypes forKey:NSStringFromSelector(@selector(acceptableContentTypes))];
}

场景二:正如上文所属,因为有可能不执行init方法,直接执行 -initWithCoder:,需要在这里做完整的初始化流程。

//AFURLSessionManager
#pragma mark - NSSecureCoding

+ (BOOL)supportsSecureCoding {
   return YES;
}

- (instancetype)initWithCoder:(NSCoder *)decoder {
   NSURLSessionConfiguration *configuration = [decoder decodeObjectOfClass:[NSURLSessionConfiguration class] forKey:@"sessionConfiguration"];

   self = [self initWithSessionConfiguration:configuration];
   if (!self) {
       return nil;
   }

   return self;
}

- (void)encodeWithCoder:(NSCoder *)coder {
   [coder encodeObject:self.session.configuration forKey:@"sessionConfiguration"];
}

iOS12 全面推行安全归档,应对文件替换攻击。

以往 iOS 防止数据篡改,主要重心放在网络传输策略,但是数据处理部分依然存在被攻击风险。而归档,正是短板所在。而所有对象归档、编解码都会走同一个方法,这对攻击者而言也十分方便。

这次 WWDC Apple 提到了文件替换攻击(iOS object substitution attack),指的是攻击者替换本地归档文件,伪造数据。从而达到数据篡改、攻击目的。

推测进程间通信 xpc ,应该是高危环节。XPC介绍可以参考: apple-documentation-xpc《ObjC 中国 - XPC》

Apple 给出的策略是,为归档的编解码方法添加,类名校验。也就是在编解码前,先检测属性的类是否是指定的类,然后再决定是否编解码。 iOS12 中,Apple 几乎废弃了 NSKeyedArchiver 和 NSKeyedUnarchiver 原有的所有方法,甚至包括init方法,然后全部加入了类名校验。提高了篡改数据的成本。

不过,
Jonathan Zdziarski 在 preventing widespread automated attacks in ios -part-2 一文中曾指出,
如果用户 hook 了 NSKeyedArchiver (或者 NSKeyedUnarchiver) 编解码方法后,runtime 拦截数据(应该也有篡改的风险),因为我对逆向了解较少,不确定本次 iOS12 的改动是否能抵御该风险,但从原理上推测,Jonathan Zdziarski 在文中指出的漏洞 iOS12 依然存在。期待后续会有更安全的应对策略。

TODO List

下面是开发者需要及时跟进的项:

  • 自定义类,Objective-C开发者,使用 NSSecureCoding ,放弃使用 NSCoding ,具体用法上文已给出示例代码。Swift开发者,启用 Codable。
  • NSKeyedArchiver、NSKeyedUnarchiver 使用新的API,让系统在编解码前校验属性类名,注意新API 最低要从iOS11/iOS12起。

附录

相关API变更:

NSCoder:

//	Foundation/NSCoder.h
// Specify what the expected class of the allocated object is. If the coder responds YES to -requiresSecureCoding, then an exception will be thrown if the class to be decoded does not implement NSSecureCoding or is not isKindOfClass: of the argument. If the coder responds NO to -requiresSecureCoding, then the class argument is ignored and no check of the class of the decoded object is performed, exactly as if decodeObjectForKey: had been called.
- (nullable id)decodeObjectOfClass:(Class)aClass forKey:(NSString *)key API_AVAILABLE(macos(10.8), ios(6.0), watchos(2.0), tvos(9.0));

NSKeyedArchiver

//	Foundation/NSKeyedArchiver.h

/**
Initializes the receiver for encoding an archive, optionally disabling secure coding.

If \c NSSecureCoding cannot be used, \c requiresSecureCoding may be turned off here; for improved security, however, \c requiresSecureCoding should be left enabled whenever possible. \c requiresSecureCoding ensures that all encoded objects conform to \c NSSecureCoding, preventing the possibility of encoding objects which cannot be decoded later.

To produce archives whose structure matches those previously encoded using \c +archivedRootDataWithObject, encode the top-level object in your archive for the \c NSKeyedArchiveRootObjectKey.
*/
- (instancetype)initRequiringSecureCoding:(BOOL)requiresSecureCoding API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0));

/**
Returns an \c NSData object containing the encoded form of the object graph whose root object is given, optionally disabling secure coding.

If \c NSSecureCoding cannot be used, \c requiresSecureCoding may be turned off here; for improved security, however, \c requiresSecureCoding should be left enabled whenever possible. \c requiresSecureCoding ensures that all encoded objects conform to \c NSSecureCoding, preventing the possibility of encoding objects which cannot be decoded later.

If the object graph cannot be encoded, returns \c nil and sets the \c error out parameter.
*/
+ (nullable NSData *)archivedDataWithRootObject:(id)object requiringSecureCoding:(BOOL)requiresSecureCoding error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0));

/// Initialize the archiver with empty data, ready for writing.
- (instancetype)init API_DEPRECATED("Use -initRequiringSecureCoding: instead", macosx(10.12,10.14), ios(10.0,12.0), watchos(3.0,5.0), tvos(10.0,12.0));
- (instancetype)initForWritingWithMutableData:(NSMutableData *)data API_DEPRECATED("Use -initRequiringSecureCoding: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));

+ (NSData *)archivedDataWithRootObject:(id)rootObject API_DEPRECATED("Use +archivedDataWithRootObject:requiringSecureCoding:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));
+ (BOOL)archiveRootObject:(id)rootObject toFile:(NSString *)path API_DEPRECATED("Use +archivedDataWithRootObject:requiringSecureCoding:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));
//
@property (readwrite) NSDecodingFailurePolicy decodingFailurePolicy API_AVAILABLE(macos(10.11), ios(9.0), watchos(2.0), tvos(9.0));

NSKeyedUnarchiver

/**
Initializes the receiver for decoding an archive previously encoded by \c NSKeyedUnarchiver.

Enables \c requiresSecureCoding by default. If \c NSSecureCoding cannot be used, \c requiresSecureCoding may be turned off manually; for improved security, \c requiresSecureCoding should be left enabled whenever possible.

Sets the unarchiver's \c decodingFailurePolicy to \c NSDecodingFailurePolicySetErrorAndReturn.

Returns \c nil if the given data is not valid, and sets the \c error out parameter.
*/
- (nullable instancetype)initForReadingFromData:(NSData *)data error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0));

/**
Decodes the root object of the given class from the given archive, previously encoded by \c NSKeyedArchiver.

Enables \c requiresSecureCoding and sets the \c decodingFailurePolicy to \c NSDecodingFailurePolicySetErrorAndReturn.

Returns \c nil if the given data is not valid or cannot be decoded, and sets the \c error out parameter.
*/
+ (nullable id)unarchivedObjectOfClass:(Class)cls fromData:(NSData *)data error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0)) NS_REFINED_FOR_SWIFT;


/**
Decodes the root object of one of the given classes from the given archive, previously encoded by \c NSKeyedArchiver.

Enables \c requiresSecureCoding and sets the \c decodingFailurePolicy to \c NSDecodingFailurePolicySetErrorAndReturn.

Returns \c nil if the given data is not valid or cannot be decoded, and sets the \c error out parameter.
*/
+ (nullable id)unarchivedObjectOfClasses:(NSSet<Class> *)classes fromData:(NSData *)data error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0)) NS_REFINED_FOR_SWIFT;

- (instancetype)init API_DEPRECATED("Use -initForReadingFromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));
- (instancetype)initForReadingWithData:(NSData *)data API_DEPRECATED("Use -initForReadingFromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));

+ (nullable id)unarchiveObjectWithData:(NSData *)data API_DEPRECATED("Use +unarchivedObjectOfClass:fromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));
+ (nullable id)unarchiveTopLevelObjectWithData:(NSData *)data error:(NSError **)error API_DEPRECATED("Use +unarchivedObjectOfClass:fromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0)) NS_SWIFT_UNAVAILABLE("Use 'unarchiveTopLevelObjectWithData(_:) throws' instead");
+ (nullable id)unarchiveObjectWithFile:(NSString *)path API_DEPRECATED("Use +unarchivedObjectOfClass:fromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));

参考文献