Swift String 底层原理
笔者将从 Swift String 的底层原理切入,循序渐进地带大家走进 Swift 逆向的世界,逐步理解其核心逻辑与实践思路。
空字符串
首先打开 XCode 创建一个 Swift 项目,然后在入口类的构造函数中添加如下代码,并且点击序号 15 打上断点。

随后点击运行,会自动卡在断点处,然后点击步过。

随后可以看到 Swift String 的内部结构了。

接下来打开 Swift 源码。
(1)在 String.swift 中检索 “struct String”

可以看到 String 结构体的构造函数中接收了一个 _StringGuts 对象,继续跟进 _StringGuts 结构体。
(2)在 StringGuts.swift 中检索 “_StringGuts”

阅读源码后可以发现 _StringGuts 结构体的构造函数接收了一个 _StringObject 对象,并且这个 _StringObject 对象是通过传入 empty 参数进行构造的,所以等会可以检索 “init(empty” 来定位 _StringObject 的构造函数。
(3)在 StringObject.swift 中检索 “init(empty”

该私有构造函数中,根据不同平台的指针位宽(64/32/16 位)适配空字符串的底层内存布局,保证空字符串在所有平台下的内存表示一致。
(3.1) _pointerBitWidth 编译条件
_pointerBitWidth(_64):Swift 编译器内置的私有编译标记,判断当前平台是否为64 位架构(如 arm64/iOS、x86_64/macOS);
_pointerBitWidth(_32)/_16:对应 32 位 / 16 位架构(极少用,如老旧的 armv7 设备、嵌入式平台);
(3.2)64 位下的代码逻辑
self._countAndFlagsBits = 0
self._object = Builtin.valueToBridgeObject(Nibbles.emptyString._value)
先明确 Swift String 的底层核心字段
| 字段 |
作用 |
_countAndFlagsBits |
存储字符串长度 + 标志位(如是否为小字符串、是否 ASCII 编码),64 位下占 8 字节 |
_object |
指向字符串底层存储的桥接对象指针(64 位下占 8 字节,对应 C++ 的 void*) |
_countAndFlagsBits = 0:空字符串长度为 0,所有标志位清零;
Builtin.valueToBridgeObject:Swift 内置(Builtin)函数,将「空字符串的静态内存地址」转换为桥接对象指针(_object);
Nibbles.emptyString:Swift 标准库中预定义的「空字符串常量」(全局唯一,避免重复创建空字符串实例,类比 C++ 的 std::string::empty() 优化)。
Nibbles 是个枚举,源码中给它加了多个 extension。进一步查看源码可以看到 Nibbles.emptyString,调用方法:small(isASCII: Bool)

通过调试也可以发现存储的地址是 0xe000000000000000

64 位平台设计逻辑
空字符串无需动态分配内存,直接复用全局唯一的 emptyString 静态地址,_object 指向该地址,_countAndFlagsBits 置 0,实现极致的内存效率(无堆分配)。
(3.3)32 位下的代码逻辑
在 StringObject.swift 文件下检索 init(count:,可以看到 32 位下调用的构造函数,因为通常是 64位,故 32位不做分析,感兴趣的可以自行了解。

(4)小结
经过上面的分析,可以知道一个字符串变量至少占用了16字节。用 MemoryLayout 工具进行验证也确实是16个字节。

非空字符串
小字符串
已知字符串变量至少占用 16个字节,那么在内存中是什么样的呢?
(1)查看小字符串的内存
首先前往[Mems]下载或者直接从Mems.zip中解压得到 Mems.swift 文件,然后导入到项目中,最后键入下面的代码。
func show<T>(val: inout T) {
print("-------------- \(type(of: val)) --------------")
print("变量的地址:", Mems.ptr(ofVal: &val))
print("变量的内存:", Mems.memStr(ofVal: &val))
print("变量的大小:", Mems.size(ofVal: &val))
print("")
}
func show<T>(ref: T) {
print("-------------- \(type(of: ref)) --------------")
print("对象的地址:", Mems.ptr(ofRef: ref))
print("对象的内存:", Mems.memStr(ofRef: ref))
print("对象的大小:", Mems.size(ofRef: ref))
print("")
}
@main
struct SwiftDemoApp: App {
init(){
var str = "1"
print("字符串:\"\(str)\"")
print("字符串所占字节数:\(MemoryLayout.size(ofValue: str))")
show(val: &str)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
上面的程序运行后可以得到下面的结果

(2)进一步验证
空字符串的 _object = 0xe000000000000000,字符串 “1” 的 _object = 0xe100000000000000,据此可合理推测,e 后四位的十六进制数值用于存储小字符串的长度,接下来将对此展开进一步验证。
查看字符串 “123” 的内存

查看字符串 “0123456789ABCDE” 的内存

查看字符串 “0123456789ABCDEF” 的内存

(3)小结
字符串长度小于 16 时,e 后的4位用于表示字符串长度,并且字符串内容存储在另外 15 个字节中。
大字符串
(1)查看大字符串的内存

(2)进一步验证
通过对比 ”0123456789ABCDEFG”与 ”0123456789ABCDEF” 的内存数据可推测,字符串长度不再存储于 _object 中,而是由 _countAndFlagsBits 字段存储;而 _object 中记录的地址偏移 0x20 后,即为字符串的实际内存地址。
查看字符串 “0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz” 的内存。

查看字符串 “泥嚎,我正在分析 swift string!” 的内存


(3)小结
大字符串的 _object 存储的是字符串的内存地址,偏移 0x20 就可以读取到字符串内容。
测试环境
测试环境分为 [ Swift 源码 ]、[ 项目源码 ] 和 一个 [ IPA 文件 ],Swift 源码和项目源码用来理解 Swift 底层结构,ipa 文件用来辅助分析。
项目源码
[SwiftDemo.zip]
IPA 安装
连接上 iPhone,打开 爱思助手,然后按照下面的步骤操作。当然,如果你有 巨魔,就可以跳过这个地方了。



工具:函数符号还原
IDA 中 Swift 函数符号是经过特殊处理的,形如 _$s10Foundation4UUIDV10uuidStringSSvg 和 _$sSS21_builtinStringLiteral17utf8CodeUnitCount7isASCIISSBp_BwBi1_tcfC 这种,前者看起来还相对明了,后者看起来就稍微费劲点,此时可以使用 xcrun swift-demangle 命令进行函数符号还原即可。
首先了解一下什么是 xcrun。 xcrun 是 macOS 下 Xcode 提供的工具链调度工具,用于定位和执行 Xcode 安装的各种开发工具(如 swiftc、lldb、clang、nm、swift-demangle 等)。
所以 xcrun swift-demangle 是 macOS 下通过 xcrun 调度的Swift 符号还原工具,核心作用是将 Swift 编译后生成的「名字重整(Mangled)符号」(晦涩的乱码式字符串)还原为可读的 Swift 函数 / 类 / 属性名。
使用方法如下:
xcrun swift-demangle '_$s10Foundation4UUIDV10uuidStringSSvg' '_$s10Foundation4UUIDVACycfC' '_$ss27_allocateUninitializedArrayySayxG_BptBwlF'
# 还原的函数符号
_$s10Foundation4UUIDV10uuidStringSSvg ----> Foundation.UUID.uuidString.getter : Swift.String
_$s10Foundation4UUIDVACycfC ----> Foundation.UUID.init() -> Foundation.UUID
_$ss27_allocateUninitializedArrayySayxG_BptBwlF ----> Swift._allocateUninitializedArray<A>(Builtin.Word) -> ([A], Builtin.RawPointer)
可能遇见的问题如下,解决方法就是使用单引号将函数符号括起来。

Swift 逆向分析
首先拟定核心目标:找到 ChaCha20 算法的明文、密钥、nonceData 及密文。
明确方向后,第一步需先了解 Swift 中 ChaCha20 算法的使用方式,你可通过 AI 自行查询,也可直接参考下文的 encryptWithChaCha20Poly1305 函数;
(1)ChaCha20Poly1305 算法在 Swift 的应用
下面是 [ 项目源码 ] 中的 encryptWithChaCha20Poly1305(message:keyHex:nonceHex:) 函数 的具体实现。
// ============================================
// ChaCha20Poly1305 加密函数
// ============================================
func encryptWithChaCha20Poly1305(
message: String,
keyHex: String,
nonceHex: String
) throws -> (ciphertext: String, tag: String) {
// 1. 将消息转换为 Data
guard let plaintext = message.data(using: .utf8) else {
throw NSError(domain: "Invalid message", code: 1)
}
// 2. 从十六进制创建密钥(32字节 = 256位)
guard let keyData = Data(hexString: keyHex), keyData.count == 32 else {
throw NSError(domain: "Key must be 32 bytes (64 hex chars)", code: 2)
}
let key = SymmetricKey(data: keyData)
// 3. 从十六进制创建 Nonce(12字节 = 96位)
guard let nonceData = Data(hexString: nonceHex), nonceData.count == 12 else {
throw NSError(domain: "Nonce must be 12 bytes (24 hex chars)", code: 3)
}
var nonce = try ChaChaPoly.Nonce(data: nonceData)
show(val: &nonce)
// 4. 执行加密
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: nonce)
// 5. 返回密文和标签的十六进制字符串
return (
ciphertext: sealedBox.ciphertext.toHexString(),
tag: sealedBox.tag.toHexString()
)
}
(2)Swift 逆向分析 之 大字符串
因为这里是知道 encryptWithChaCha20Poly1305 函数传入的是三个 Swift 字符串,所以可以直接通过分析寄存器:x0, x1, x2, x3, x4, x5,从而找到明文、密钥以及 nonce 是什么。接下来进入实战环节:先使用 debugserver 启动 SwiftDemo,再通过 lldb 或者 xia0lldb 完成连接(之所以不使用 XCode 自带调试器,是因为在函数中打断点后,它会跳过大量汇编代码,会对后续分析造成阻碍,最主要是 静态偏移会发生变化)。
(2.1)首先用 IDA 打开 SwiftDemo.debug.dylib 二进制,并且找到 encryptWithChaCha20Poly1305 函数的偏移地址。

(2.2)使用 lldb 进行动态调试,笔者这边使用了ιldb,这是笔者开发的一个用于 lldb 的脚本工具。

(2.3)下好断点后,直接 continue 一下,进入 encryptWithChaCha20Poly1305 函数,紧接着跟着下面的步骤进行操作。
(2.4)从这可以得到 encryptWithChaCha20Poly1305 函数传入的三个参数的内容分别为:
var message = "Hello World!Hello World!Hello World!"
var keyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
var nonceHex = "000102030405060708090a0b"
(2.5)那这样总结一下规律,便可以写出一个 ιldb 脚本,这样下次解析就十分方便了,如下所示。

(3)Swift 逆向分析 之 授人以渔
看到这里有人就要说了,这是我们知道函数的原本模样,所以分析起来轻轻松松,换一个 App 还是有点难以入手,比如下面这种没有函数符号的函数,那该如何判断各个参数的含义呢?

那肯定就不判断了呀,这种就找返回值,反方向往前推,如下所示。

假设 encryptWithChaCha20Poly1305 函数已被混淆为 sub_ 形式,其参数也相应变为 id a1, id a2 这类格式,不过我们已经成功定位到此处的返回值正是所需数据;至于定位的具体方法,则需要一定的技术功底支撑,无论是采用 hook 手段还是静态分析方式均可实现,而这部分内容并非本节的重点。
接下来我将一步一步介绍如何通过返回值找到 Swift 的重要数据。
(3.1) 打开 IDA 找到 encryptWithChaCha20Poly1305 函数,RET 语句的位置,如下所示:

(3.2)可以打开 lldb,并加载 ιldb 脚本,将断点下到此处看看,然后尝试解析为 Swift 字符串,得到的结果如下所示。

(3.3)验证成功,接下来打开 IDA,跟一下是怎么来的:

继续跟

上图的 ② 说是 Data 对象可能无法让人信服,那么接下来,我将一步一步证明。
(3.3.1)首先打开 XCode,随便写一个 Data 对象,代码如下所示,
func testSwiftBase64Encode(){
var str = "Hello World!"
// 字符串转 Data
guard var data = str.data(using: .utf8) else {
print("字符串转 Data 失败")
return
}
show(val: &data)
// Data 转 Base64 字符串(默认不换行,URL 安全)
let base64Str = data.base64EncodedString()
print("Base64 编码结果:\(base64Str)")
}
(3.3.2)将断点断在 testSwiftBase64Encode 函数,当断点步过 show(val: &data) 这一行时,观察下面的信息。

有了前面分析 Swift String 的经验,相信此处也有一定的手法,毕竟像你这样的大师 ◖⚆ᴥ⚆◗。
(3.3.3)此处看到了字节 “0x0c”,,这个想必是 Data 对象中字节数组的一个长度了,并且和字符串一样,可能是分为了大 Data 和小 Data,而且长度的阈值便是 ”0x0e”。空口无凭,接下来继续验证。
(3.3.3.1) 首先将字符串修改为一个你喜欢的字符串,但是长度必须是 14,然后进行调试,结果如下所示:

可以发现那个字节确实是长度无疑了,那么接下来验证长度的阈值是 “0x0e”
(3.3.3.2)将字符串修改为一个长度为 15 的字符串,然后进行调试,结果如下所示。

这个地方稍微复杂一点,我大概解释如下几个地方:
首先最重要的就是 ⑧ 处,可以看到 str 变量的地址是:0x16b9093b0,所以不要把这个地方当成是 Data 中字节数组的地址,而且跟 ④ 中,_bytes 的指针也对不上,所以还需要继续找;
根据前面分析大字符串的经验,合理怀疑这个地方的 ”0x0f” 说的是 Data 中字节数组的长度。其次就是 0x40006000021054f0 是存储着字节数组的地址。
(3.3.3.3)口空无凭,接下来继续分析和验证。

通常来说我建议顺手多测试一下,如下所示

由于 0x1a 对应十进制 26,且该字节数值会随字符串长度变化而对应改变,因此大 Data 低八字节中(高四字节的最低字节)存在一个表示长度的字节;
此外,执行 memread -ptr 0x400060000210c410+0x10 命令后读取到的是 0x600000206840 的内容,由此可知大 Data 高八字节偏移 0x10 字节的位置存储着 Data 字节数组的内存地址。
(3.3.3.4)经过上面的分析后, 那你便可以写一个 lldb 脚本用于处理 Swift Data 对象了,如下所示,


(3.4)回到正题,使用 sd 命令测试一下是否为 Swift Data 数据。把断点 mark 到 AFD0 处,如下所示。

(3.5)接下来再往上跟,

继续跟

发现数据来源于 static ChaChaPoly.seal<A>(_:using:nonce:),此时不知道这个函数的功能不要紧,丢给你尊敬的 艾老师让 ta 帮你分析一下。这边我就不演示了,艾老师的意思是:代码层面是 ChaChaPoly.seal(plaintext, using: key, nonce: nonce) 这个样子。
那么如果你不小心让艾老师生成一个使用这个函数的代码样本,其实就是上面介绍的 encryptWithChaCha20Poly1305 函数,接着你再不小心把艾老师提供给你的代码样本放在 XCode 中进行动态调试,那么你将知道 ChaChaPoly.seal(plaintext, using: key, nonce: nonce) 返回的数据是怎么个事了,如下所示。

可以发现 ChaChaPoly.seal 函数返回的 sealedBox 是一个 SealedBox 对象,不过其内容实现是 Data 对象。
而且可以发现 Data 对象中字节数组的前 12个字节其实就是 nonce。那么 nonce 后面的这些数据都是什么呢?其实我也不知道,但是如果你不小心点了 continue,让程序继续运行的话,就会知道一个是密文,一个是标签,如下所示。

好,到此为止,你已经不小心完成了一半了,毕竟你已经找到了密文及其标签,还有 nonce 了 。
(3.6)很好,看到这里你已经很棒了,如果累了记得放松一下大脑。
(3.7)很好,你真是不小心看到了这一行,想必你也已经放松完了,那么接下来让我们来寻找明文还有密钥。
首先还是继续观察 ChaChaPoly.seal(plaintext, using: key, nonce: nonce) 函数,请使用 XCode 查看一下 plaintext 以及 key 的数据类型,如下所示。

(3.8)接下来回到 IDA 继续静态分析

你会发现这根本不对劲,因为总共传入了三个对象,按道理应该是用了 6 个寄存器啊,这怎么回事呢?
我先说结论,Swift 部分底层函数会先将寄存器中的数据写入栈空间,再把该栈空间的内存地址作为参数传递给目标函数。 理解不了结论没关系,因为你没有看懂过程,直接看结论是会有点懵的。
(3.8.1)静态分析 x0 的出处,如下所示

下面这个地方十分关键,主要是理解 0xAE80 和 [0xAE50, 0xAE5C] 对应汇编代码之间的关系。

这边我直接画了个图,供大家理解和参考。

再往上跟,可以发现很早之前就在为后面做铺垫了

来,继续往上跟

跟到最后你会发现是将一个 Swift String 转换成了 Data 对象。

(3.9)接下来在 AB90 处打上断点,查看一下数据,这个就是算法的明文数据了。

(3.10)你终于还是不小心看到了这里,恭喜我,我终于已经教完了,可是有些人就有疑问了,感觉好像少了点啥。是的没错,还有密钥没有分析,这个其实就是给你的挑战了,我如果全给你分析了,那你缺少实战经验,还是会很快忘记的(说白了是我懒)。
最下面我给了你一个锦囊,当你发现还是找不到密钥,就去看看吧,如果还是没找到,可以来云栈社区与大家共同讨论。
总结
恭喜恭喜,你已经不小心学会了如下技能:
(1)分析 Swift 类的底层原理;
(2)逆向Swift;
(3)使用 ιldb 脚本;
(4)使用 xcrun swift-demangle 工具;
(5)ChaCha20Poly1305 算法在 Swift 中的应用;
当然还有你的动态调试熟练度也+1,如果你已经满级当我没说。
锦囊:关于SymmetricKey 类型的提示
指针偏移 0x20 就是 key

# let keyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
(lldb) memread 0x00006000017004c0
0x6000017004c0: b0 f1 54 f3 01 00 00 00 03 00 00 00 00 00 00 00 ..T.............
0x6000017004d0: 20 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00 ....... .......
0x6000017004e0: 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef .#Eg.....#Eg....
0x6000017004f0: 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef .#Eg.....#Eg....
0x600001700500: ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 ................