WWDC Quick Look 💓 By SwiftGGTeam
Secure your app: threat modeling and anti-patterns

Secure your app: threat modeling and anti-patterns

观看原视频

Highlight

Apple 用路径穿越、会话状态、反序列化白名单、缓冲区长度和整数溢出五个代码片段说明,威胁建模要落到每个外部输入进入 App 的检查点上。

核心内容

安全问题很少只出现在加密算法这一层。更常见的情况是,App 把外部输入当成了内部数据:远端传来的文件名、另一台设备发来的会话消息、CloudKit 记录里的二进制字段、反序列化阶段声明的类集合,都可能成为攻击入口。

这场 session 的材料把威胁建模拆成代码级问题。开发者先找出数据从哪里进入 App,再问三个问题:这个值能不能改变文件路径,它能不能推动状态机,它会不会让运行时创建不该创建的对象,长度和数量有没有在进入内存操作前验证。

Apple 给出的防护方式也很直接。文件名先收敛到 lastPathComponent;会话消息必须同时匹配 session、状态和发送者;动态类集合由协议约束生成;固定大小内存复制前检查类型和长度;从数量计算字节数时使用 os_mul_overflow。这些检查看起来小,却决定了攻击者能不能越过信任边界。

详细内容

1. 路径穿越:把外部文件名收敛成单个安全文件名

16:34)文件导入功能常见的错误,是把远端传来的 name 直接拼到本地目录后面。片段先取 lastPathComponent,再拒绝空字符串、...,最后才写入临时目录。

func handleIncomingFile(_ incomingResourceURL: URL, with name: String, from fromID: String) {
    guard
         case let safeFileName = name.lastPathComponent,
         safeFileName.count > 0,
         safeFileName != "..", safeFileName != "." else { return }

    let destinationFileURL = URL(fileURLWithPath: NSTemporaryDirectory())
                                 .appendingPathComponent(safeFileName)

    // Copy the file into a temporary directory
    try! FileManager.default.copyItem(at: incomingResourceURL, to: destinationFileURL)

}

关键点:

  • name.lastPathComponent 去掉外部输入里携带的目录层级,只保留最后一个路径组件。
  • safeFileName.count > 0 拒绝空文件名,避免生成无意义目标路径。
  • safeFileName != ".."safeFileName != "." 拒绝当前目录和上级目录这两个特殊路径组件。
  • NSTemporaryDirectory() 把导入文件限制在临时目录,再通过 appendingPathComponent 拼接经过检查的文件名。

2. 状态管理:远端消息必须匹配当前状态和发送者

22:26)会话邀请接受消息来自远端,不能只凭 sessionIdentifier 就把连接状态切到已连接。代码同时检查 session 是否存在、状态是否仍在邀请中、发送者是否在被邀请列表里。

func handleSessionInviteAccepted(with message: RemoteMessage, from fromID: String) {
    guard session = sessionsByIdentifier[message.sessionIdentifier],
          session.state == .inviting,
          session.invitedFromIdentifiers.contains(fromID) else { return }

    session.state = .connected
    session.setupSocket(to: fromID) { socket in
        cameraController.send(to: socket)
    }
}

关键点:

  • sessionsByIdentifier[message.sessionIdentifier] 先确认消息引用的是一个已知 session。
  • session.state == .inviting 把状态转换限制在合法阶段,旧消息不能随意推进状态。
  • session.invitedFromIdentifiers.contains(fromID) 把操作绑定到被邀请的发送者。
  • session.state = .connectedsetupSocket 只在三项检查全部通过后执行。

3. 反序列化白名单:动态集合也要从明确规则生成

30:56)需要动态构建 allowedClasses 时,片段没有接受任意类名,而是遍历运行时类列表,只收集符合某个协议的类。这样得到的集合仍然有边界。

NSSet *classesWhichConformToProtocol(Protocol *protocol) {
    NSMutableSet *conformingClasses = [NSMutableSet set];

    unsigned int classesCount = 0;
    Class *classes = objc_copyClassList(&classesCount);
    if (classes != NULL) {
        for (unsigned int i = 0; i < classesCount; i++) {
            if (class_conformsToProtocol(classes[i], protocol)) {
                [conformingClasses addObject: classes[i]];
            }
        }
        free(classes);
    }
    return conformingClasses;
}

关键点:

  • objc_copyClassList 从 Objective-C runtime 取出当前进程里的类列表。
  • class_conformsToProtocol(classes[i], protocol) 是白名单条件,只有声明符合协议的类才进入集合。
  • free(classes) 释放 runtime 返回的类数组,避免辅助检查代码引入内存泄漏。
  • 返回值是一个 NSSet,适合作为后续安全解码流程里的允许类集合。

4. 缓冲区溢出:复制固定长度字段前先验证类型和长度

34:23)CloudKit 记录里的字段来自外部存储。把 NSData 复制到固定大小的 _uuid 之前,片段先确认字段存在、类型正确、长度等于目标缓冲区大小。

@implementation
- (BOOL)unpackTeaClubRecord:(CKRecord *)record {
    ...
    NSData *data = [record objectForKey:@"uuid"];
    if (data == nil ||
        ![data isKindOfClass:[NSData class]] ||
        data.length != sizeof(_uuid)) {
        return NO;
    }
    memcpy(&_uuid, data.bytes, data.length);
    ...

关键点:

  • data == nil 拒绝缺失字段,后面的内存操作不会面对空对象。
  • isKindOfClass:[NSData class] 确认字段类型,避免把别的对象当二进制缓冲区处理。
  • data.length != sizeof(_uuid) 把输入长度和目标缓冲区大小绑定。
  • memcpy 只在长度检查后执行,复制字节数来自已验证的 data.length

5. 整数溢出:先安全计算字节数,再创建字符串

36:06)字符串长度字段也来自记录。片段没有直接用 count * sizeof(unichar),而是用 os_mul_overflow 计算字节数,并把 NSData.length 与计算结果对齐后再初始化字符串。

@implementation
- (BOOL)unpackTeaClubRecord:(CKRecord *)record {
    ...
    NSData *name = [record objectForKey:@"name"];
    int32_t count = [[record objectForKey:@"nameCount"] unsignedIntegerValue];
    int32_t byteCount = 0;
    if (name == nil ||
        ![name isKindOfClass:[NSData class]] ||
        os_mul_overflow(count, sizeof(unichar), &byteCount) ||
        name.length != byteCount) {
        return NO;
    }
    _name = [[NSString alloc] initWithCharacters:name.bytes
                                          length:count];
    ...

关键点:

  • namenameCount 来自同一条记录,使用前都要当作外部输入处理。
  • os_mul_overflow(count, sizeof(unichar), &byteCount) 在乘法结果放入 byteCount 前检查溢出。
  • name.length != byteCount 确认字节缓冲区长度和字符数量一致。
  • initWithCharacters:length: 只在字节数验证后调用,避免把不完整缓冲区解释成字符串。

核心启发

  1. 文件导入安全网 做什么:把 AirDrop、文档选择器、分享扩展和下载任务进入 App 的文件名统一交给一个清洗函数。 为什么值得做:路径穿越片段说明,外部文件名进入路径拼接前必须先去掉目录层级并拒绝特殊路径组件。 怎么开始:写一个 safeLocalFileName helper,覆盖空字符串、...../../name、绝对路径和超长文件名测试,再把文件复制限制在临时目录或 App 私有容器内。

  2. 远端消息状态机 做什么:为协作、投屏、即时通信或设备互联功能画出状态表,列出每条远端消息允许触发的状态转换。 为什么值得做:会话示例要求 session 存在、状态为 .inviting、发送者在邀请列表中,缺一项都不能建立 socket。 怎么开始:把 statemessageTypesenderID 和当前权限合并成一个 guard 函数,单元测试覆盖重复消息、旧消息和陌生发送者。

  3. 反序列化允许类注册表 做什么:把所有允许被解码的模型类放到显式协议或注册表里,禁止调用点临时拼接类名列表。 为什么值得做:classesWhichConformToProtocol 片段用协议约束动态集合,保留灵活性,同时让边界可审计。 怎么开始:定义一个只用于安全解码的 marker protocol,让需要解码的类显式声明符合协议,再在解码入口生成 NSSet

  4. CloudKit 字段解包校验层 做什么:为 CKRecord 里的二进制字段、计数字段和字符串字段建立统一解包函数。 为什么值得做:两个 CloudKit 片段都在进入 memcpyNSString 初始化前检查 NSData 类型、准确长度和乘法溢出。 怎么开始:先把 UUID、name 这类字段改成 readUUID(record:key:)readUTF16Name(record:dataKey:countKey:) 之类的小函数,再补齐畸形记录测试。

  5. PR 级威胁建模清单 做什么:每个引入外部输入的 PR 都回答四个问题:会不会写文件路径、会不会推进状态、会不会解码对象、会不会按长度复制内存。 为什么值得做:这场 session 的代码片段把安全审查压缩成几个具体入口,比泛泛讨论“安全性”更容易落地。 怎么开始:在 PR 模板里加入这四项检查,并要求相关代码附上至少一个恶意输入测试。

关联 Session

评论

GitHub Issues · utterances