转自:(一场开源 RSA 库引发的“血案”)
导读
RSA 加密算法是一种非对称加密算法,该算法极为可靠,在现有技术条件下,很难破解,因此在软件开发中被广泛使用。你不必担心,本文不会介绍深奥的 RSA 加密算法,也没有复杂的数学公式。本文将结合 58 iOS App 项目实践,分享一次我们奇异的 Bug 排查经历,谈谈 GitHub 上一个知名的 RSA 算法库 Objective-C-RSA 使用过程中遇到的一些暗坑。我们调研发现许多 SDK(如:高德地图、快手激励视频、电信一键登录、 极验等) 和 App(如:百度、京东、快手、美团、淘宝、微信等) 都使用了该库,经反复测试在并发的情况下同时利用该库生成不同的RSA公钥会有30%甚至更高的概率出现冲突,导致加密结果异常。希望本文对读者有所启示,避免出现类似问题。
问题背景
在 GitHub 上搜索 RSA,选择 Objective-C 语言过滤,结果如下:
在匹配度、点赞、复刻等多维度搜索结果中,Objective-C-RSA 都名列第一,可以称得上是 ObjC 中质量最好的 RSA 算法开源库。在我们事后的调研中,发现快手激励视频 、高德地图、电信免密登录、极验等许多三方 SDK 都使用了该算法库,另外百度、京东、快手、美团、淘宝、微信等大型 App 都直接或间接的依赖了该库。
使用 strings 查找字符串
使用反编译工具查找 RSAUtil_PubKey 字符串引用
注:1. 上述分析只查找了各 App 主二进制中是否存在 Objective-C-RSA 库中的 RSAUtil_PubKey 字符串,结果仅供参考,如有出入,实属正常。2. 在并发环境下,RSA 加密会有一定概率出现异常,非并发环境一般不会有问题。各 App 应根据业务场景,具体问题具体分析。
在最近的项目中,我们也有用到 RSA 算法,Objective-C-RSA 作为 iOS 端质量最佳的 RSA 开源库,开发同学优先选择了该库。由于当时项目特殊,开发周期比较紧张,整个开发、测试过程中,都没能发现潜在的问题。待项目上线后,后端同学监控发现,RSA 解密过程出现大量异常数据,主要包括以下两种错误类型:
RSA 加密结果为空
加密数据无法解密
都是并发惹的祸
在分析服务器端提供的错误日志后,客户端研发同学第一反应可能是并发引起的问题。仔细阅读Objective-C-RSA 说明文档后,发现作者在 README 文档中简单提到线程不安全,但具体原因未写。而后又翻阅了相关 issue,曾有人提到过该问题(详见 issue#50),作者回复给出的方案是“避免在业务层多线程调用加密方法”。作为一千多 Star 的开源库,无论在说明文档,还是 issue 解决中,如此草率,真是坑人,开发同学看完直欲喷人。要是 Linus Torvalds 看到这样的代码,画面简直不敢想象。
问题至此,只怪自己学艺不精,喷亦无用,还是要优先解决问题。开发同学通过编写并发测试用例,发现在 addPublicKey: 方法中,调用SecItemCopyMatching 方法时,会出现查询数据失败的情况,错误码为-25300,具体错误原因为The specified item could not be found in the keychain,即『在 Keychain 中未找到指定的数据项』。
线程不安全,难道 Keychain 中的 API 线程不安全吗?这锅应该苹果来背,开始我们也是这样认为的。之后又查阅的了更多资料,发现许多开发者都有 Keychain API 是否线程安全的困惑。苹果在官方文档 Certificate, Key, and Trust Services 并发章节中提到:
In macOS, some of the functions of this API block while waiting for input from the user (for example, when the user is asked to unlock a keychain or give permission to change trust settings). In general, it is safe to use this API in threads other than your main thread, but avoid calling the functions from multiple operations, work queues, or threads concurrently. Instead, serialize function calls or confine them to a single thread.In iOS, all the functions in this API are thread-safe and reentrant.
大意翻译一下:
在 macOS 中,证书、密钥、信任服务 API 中的一些函数在等待用户输入时会被阻塞(例如,当用户被要求解锁钥匙串或允许改变信任设置时)。一般来说,在线程中使用该 API 是安全的,不仅仅是主线程,但要避免在多个操作、工作队列或线程中同时调用这些函数。相反,要将函数调用序列化,或将其限制在一个线程中。在 iOS 中,该 API 中的所有函数都是线程安全和可重入的。
从苹果官方文档看来,Keychain 中相关的 API 都是线程安全的,那么问题又出自哪里?我们来看下 +[RSA addPublicKey:] 方法的具体实现:
上述方法使用钥匙串 API 将 RSA 公钥字符串,添加到钥匙串中,最后从钥匙串中读取返回 SecKeyRef 结构。我们知道 Keychain 中的数据存储于系统提供的共享数据库中,是位于磁盘上的数据。在越狱设备中使用Keychain-Dumper工具可以查看其中数据。
作者在生成 RSA 公钥数据结构 SecKeyRef 过程中,同时使用了钥匙串的删除(SecItemDelete)、添加(SecItemAdd)、查询(SecItemCopyMatching)接口,其中每个 API 都是线程安全的。真正的问题在于,多线程环境中组合使用上述 API,同时读写钥匙串中的数据,并不能保证数据正确性。当一个线程在读取钥匙串中数据时,另外一个线程碰巧将数据删除,这时会出现读取钥匙串数据为空的情况,此正是上文提到的-25300错误的原因。
命名的艺术
搞明白问题出现的原因,处理完并发问题,你以为问题就解决了!曾以为这段代码的坑在第一层,谁也料不到坑在地下十八层。软件开发中命名确实是让很多人头疼的事情,即使我们不追求优雅的艺术,但也要留神避免冲突,那么问题与命名又有何关系?
上文提到服务器端反馈过两种错误类型,解决完并发问题,RSA 加密结果为空的数据得到解决。但另外一个问题,客户端 RSA 加密数据,服务器端解密异常,在我们上百万并发测试的过程中,始终没能复现(当时使用了测试 Demo 验证,与工程环境有所差异)。曾一度提出各种猜想:双端 RSA Padding 不一致、ObjC 与 Java 端 RSA 算法兼容性有问题、后端的解密代码有问题、亦或数据传输过程中有丢失。通过仔细分析,这些假定都被我们一一排除。在始终找不到头绪的情况下,又让后端提供了数万条错误日志,一位细心的同事在其中发现了端倪,拨开层层迷雾,看到一缕曙光。
在后端提供的错误日志中,少量解密失败的数据,长度恰好是正常数据的一半,加密结果莫明其妙短了一半,这仅仅是巧合吗?我们知道 RSA 加密后的密文位长跟密钥的位长度是相同的,2048 位的密钥长度,为何会出现 1024 位的加密结果?再次细读 Objective-C-RSA 加密实现代码,还是在 +[RSA addPublicKey:] 方法中,读写钥匙串数据相关代码,使用了一个 TAG 标记(kSecAttrApplicationTag),其默认值为 RSAUtil_PubKey,灵光乍现,拍案而起,莫非与其他 SDK 有冲突!
为了验证我们的猜测,需要确认其他 SDK 中是否使用了同样的 TAG。结合以往的逆向经验,我们首先想到的方法是反编译 App,搜索字符串,查找数据引用,结果如下图所示:
其中一个方法,由集团内部 SDK 实现,与相应研发人员沟通,使用对方私钥,测试验证可以解密部分错误日志,在实践层面进一步证实我们的猜测:各 SDK 之间使用相同 TAG 读写 RSA 公钥,并发环境下会出现脏读,导致加密数据混乱。
为解决工程中其他 SDK 相互影响出现问题,我们进一步排查哪些 SDK 使用过默认的 TAG 值。在 Hopper 中反编译,可以查找引用函数,但不易判断函数出自哪个 SDK。想到 strings 命令可以在二进制文件中查找字符串,我们编写了一个脚本,输入文件目录,可以批量查找 .framework 和 .a 库中是否存在指定的字符串。
脚本核心实现如下,完整脚本可移步 (WBBlades) 下载。
search_liba() {
dir=$1
for lib in $(find $dir -type f -name "*.a");
do
cnt=$(strings $lib | grep $keyword -wc)
if [[ $cnt -gt 0 ]]; then
echo "$lib -> $keyword($cnt)"
fi
done
}
search_framework() {
dir=$1
for lib in $(find $dir -type d -name "*.framework");
do
lib_name=${lib##*/}
lib_name_without_ext=${lib_name%.framework}
lib_full_name=$lib/$lib_name_without_ext
if [[ -e "$lib_full_name" ]]; then
cnt=$(strings $lib_full_name | grep $keyword -wc)
if [[ $cnt -gt 0 ]]; then
echo "$lib_full_name -> $keyword($cnt)"
fi
fi
done
}
使用脚本搜索字符串 RSAUtil_PubKey,部分查找结果如下:
the keyword you input: RSAUtil_PubKey
/AMapFoundationKit.framework/AMapFoundationKit -> RSAUtil_PubKey(4)
/AnyThinkSDK.framework/AnyThinkSDK -> RSAUtil_PubKey(5)
/EAccountApiSDK.framework/EAccountApiSDK -> RSAUtil_PubKey(4)
/GT3Captcha.framework/GT3Captcha -> RSAUtil_PubKey(4)
/KSAdSDK.xcframework/ios-arm64_armv7/KSAdSDK.framework/KSAdSDK -> RSAUtil_PubKey(2)
......
搜索结果着实让人吃惊,本以为是我们一时疏忽导致的问题,没想到是高德地图、快手激励视频、电信一键登录、极验等诸多 SDK 都使用了默认的 TAG 值(注:上述结果只表明 SDK 中存在 RSAUtil_PubKey 字符串,是否会引起冲突,需要进一步调试验证。限于 SDK 版本,结果仅供参考),很容易与调用方出现冲突,这也正是本文写作初衷,希望能够引起大家重视。
至此问题出现的根源我们已剖析完成,机智的同学可能已经发现,这两个问题本质上可以归结为同一个问题,都是由于并发导致的数据脏读。多个 SDK 在构造 RSA 公钥数据结构,读取 Keychain 共享资源时都使用了同一个 TAG,相互之间产生影响,不仅自己代码并发会导致错乱,还会与其他 SDK 发生冲突。再进一步分析这个问题,即便每个SDK的TAG值不同,但每个SDK内只要存在同一时间获取不同公钥的逻辑,依然有可能使SDK内部的不同公钥出现冲突。
这里提供一种最简单的解决方案,修改 +[RSA addPublicKey:] 方法的 TAG为随机值,例如使用 UUID,保证每次读写 Keychain 数据时的唯一性,不仅可以避免自己代码并发产生的脏数据,还可以避免与其他 SDK 发生冲突。另外 RSA 加密是一个相对耗时的操作,该方法在每次加密时调用,同时执行钥匙串的删除、添加、读取操作,设计是否合理也有待商榷。
总结
本文分享了 58 iOS App 项目使用 Objective-C-RSA 三方库,遇到的一些坑,详细剖析了问题出 现的原因。 在分析过程中我们发现,此现象并非个例,许多 SDK 都存在同 样的漏洞,或许由于使用场景限制,暂时没有发现潜在的问题,希望能够引起诸位同行重视。
客户端研发过程中,并发问题容易被人忽略,QA 测试环节也很难发现。代码中涉及操作共享数据(内存数据、磁盘数据)时,一定要多考虑线程安全、资源竞争问题,建议开发者自己编写并发测试 case 验证。
在分析定位问题时,我们使用了逆向技术、脚本等多种手段,还分析了上万条错误日志,通过分析数据发现问题,灵活运用工具解决问题,有助于快速定位疑难问题,提升工作效率。
开源项目让我们普通开发者受益匪浅,在此感谢开源作者的无私奉献。与此同时,开源项目也深深影响着每一位用户,想起几年前 Ant Design 圣诞彩蛋事件和最近的 log4j 漏洞,诚可畏也!
在软件开发中,我们不提倡重复造轮子,但在使用轮子的过程中,一定要了解其中构造,知其然知其所以然。死搬硬套,一不小心就会被带到沟里,勿谓言之不预也。
参考资料:
1.(Objective-C-RSA)
2.(is keychain in ios threadsafe?)
3.(Working with Concurrency)
4.(WBBlades - search symbol script)