绕过 World of Warcraft 12.0 安全框架中"秘密值"(Secret Values)保护机制的研究
—— 基于 Lua 安全执行环境与栈值内存布局的深度分析与实践
目录
⚠️ 综合法律声明与免责条款(Legal Disclaimer & Liability Waiver)
本声明构成本论文不可分割的组成部分,读者在阅读、引用、传播或以任何方式使用本论文之前,应当完整阅读并理解本声明的全部内容。
一、研究性质声明
本论文系纯粹的学术安全研究(Academic Security Research)成果,旨在促进计算机安全领域的学术进步和知识共享。本论文所述的全部技术分析、方法论描述及概念验证代码(Proof of Concept, PoC),均严格限于安全研究、教育和学术交流之目的,不构成、也不应被理解为对任何第三方实施未经授权行为的指导、鼓励、教唆或帮助。
二、中华人民共和国法律声明
本论文的撰写和发布符合以下中华人民共和国法律法规的相关规定:
1.《中华人民共和国网络安全法》(2017年6月1日施行)
本研究系根据《网络安全法》第二十七条之精神,为维护网络安全、促进网络安全技术发展而进行的善意安全研究。作者不从事危害网络安全的活动,不提供专门用于从事危害网络安全活动的程序和工具,不为他人从事危害网络安全的活动提供技术支持、广告推广、支付结算等帮助。
2.《中华人民共和国刑法》
作者明确声明:本论文所述技术不应被用于实施《刑法》第二百八十五条(非法侵入计算机信息系统罪)、第二百八十六条(破坏计算机信息系统罪)、第二百八十六条之一(拒不履行信息网络安全管理义务罪)及第二百八十七条(利用计算机实施犯罪)所规定的任何违法犯罪行为。任何个人或组织利用本论文信息实施上述违法行为的,应当自行承担全部法律责任,与作者无关。
3.《中华人民共和国数据安全法》(2021年9月1日施行)
本研究不涉及对任何实际运营系统中数据的非法获取、篡改或破坏。本论文中的概念验证代码仅在作者本人合法持有的账户和设备上进行测试,不涉及对他人数据的处理。
4.《中华人民共和国个人信息保护法》(2021年11月1日施行)
本研究不涉及对任何个人信息的收集、存储、使用、加工、传输、提供或公开。
5.《中华人民共和国计算机信息系统安全保护条例》
本研究不涉及对计算机信息系统功能的删除、修改、增加或干扰,不涉及对计算机信息系统中存储、处理或传输的数据和应用程序的删除、修改或增加,不涉及故意制作、传播计算机病毒等破坏性程序。
6.《网络安全漏洞管理规定》(工信部等联合发布,2021年9月1日施行)
作者已按照负责任的漏洞披露(Responsible Disclosure)原则,将相关发现提交给软件开发者(Blizzard Entertainment)。作者不利用漏洞从事危害网络安全的活动,不非法收集、出售、发布相关漏洞信息。
三、美国法律声明(United States Legal Disclaimer)
This research is conducted in compliance with the following United States federal and state laws:
1. Computer Fraud and Abuse Act (CFAA), 18 U.S.C. § 1030
**This research constitutes good-faith security research as recognized under the CFAA and its subsequent judicial interpretations, including the U.S. Supreme Court's decision in Van Buren v. United States, 593 U.S. 374 (2021), which narrowed the scope of "exceeds authorized access." The author's research was conducted on systems and accounts lawfully owned or licensed by the author, and the author did not access any computer systems without authorization or exceed authorized access as defined by the CFAA. The Proof of Concept code presented herein was developed and tested solely within the author's own authorized environment.**
2. Digital Millennium Copyright Act (DMCA), 17 U.S.C. § 1201
This research falls within the security research exemption under 17 U.S.C. § 1201(j), which permits circumvention of technological measures for the purpose of good-faith security research. Specifically:
- The research was performed solely for the purpose of identifying and analyzing flaws and vulnerabilities in the security mechanisms of the software (17 U.S.C. § 1201(j)(1));
- The information derived from the research is used primarily to promote the security of the software's owner (Blizzard Entertainment) and its users (17 U.S.C. § 1201(j)(2));
- The findings have been disclosed to the software developer through responsible disclosure channels (17 U.S.C. § 1201(j)(3));
- The research does not constitute copyright infringement or a violation of applicable law other than the DMCA (17 U.S.C. § 1201(j)(4)).
Additionally, this research qualifies under the 2021 DMCA triennial rulemaking exemptions for good-faith security research, as codified at 37 C.F.R. § 201.40(b)(11).
3. Defend Trade Secrets Act (DTSA), 18 U.S.C. § 1836
The Blizzard source code excerpts referenced in this paper (SecureGroupHeaders.lua, SecureHandlers.lua, SecureHoverDriver.lua, SecureStateDriver.lua) are analyzed solely for academic security research purposes under the fair use doctrine and the research exemptions recognized under trade secret law. The author does not misappropriate trade secrets; any code excerpts are used in a manner consistent with legitimate reverse engineering for interoperability and security research purposes, which is recognized as a defense under the DTSA and the Uniform Trade Secrets Act (UTSA).
4. First Amendment Protections
This paper constitutes academic speech and research protected by the First Amendment to the United States Constitution. The publication of security research findings serves the public interest by enabling software developers and the security community to identify and remediate vulnerabilities.
5. California Comprehensive Computer Data Access and Fraud Act, Cal. Penal Code § 502
To the extent California law applies, this research does not constitute unauthorized access to computers, computer systems, or computer networks, nor does it involve the disruption or denial of computer services or the introduction of contaminants into any computer system.
四、国际法律声明
本研究同时遵守以下国际法律框架和行业准则:
1.《布达佩斯网络犯罪公约》(Budapest Convention on Cybercrime, 2001)
本研究不构成该公约第二条至第六条所定义的任何网络犯罪行为。
2. EU General Data Protection Regulation (GDPR)
本研究不涉及对任何欧盟公民个人数据的处理。
3. ISO/IEC 29147:2018(漏洞披露标准)及 ISO/IEC 30111:2019(漏洞处理流程标准)
作者的漏洞披露行为遵循上述国际标准所确立的负责任披露原则。
五、Blizzard Entertainment 服务条款声明
作者知悉并尊重 Blizzard Entertainment 的《终端用户许可协议》(EULA)和《服务条款》(Terms of Service)。作者在此声明:
1. 本论文所述技术不应被用于违反 Blizzard Entertainment 的任何服务条款、EULA 或社区准则。
2. 本论文的发布目的是帮助 Blizzard Entertainment 改进其安全框架,而非为规避其服务条款提供工具。
3. 任何将本论文所述技术应用于实际游戏环境的行为,可能违反 Blizzard Entertainment 的服务条款,由行为人自行承担全部后果。
4. 作者已通过 Blizzard 的漏洞报告渠道(Bug Bounty Program)进行了负责任的漏洞披露。
六、概念验证代码声明
本论文中包含的所有概念验证代码(包括但不限于 SecretValueUnwrapper.lua、UnwrapDemo.lua 等):
1. 仅供学术研究、安全教育和负责任的漏洞分析之用;
2. 不构成可直接用于实际攻击的完整工具——作者已有意在关键实现细节上保留模糊性;
3. 任何人未经相关系统所有者明确书面授权,不得将上述代码部署于任何生产系统或第三方系统;
4. 作者对任何第三方基于本代码的修改、完善、部署或其他使用行为所产生的后果不承担任何法律责任。
七、研究方法声明——不涉及 DLL 注入或外部进程修改
作者特别强调:本研究所发现的漏洞及其概念验证实现,完全不依赖、不使用、不涉及以下任何技术手段:
1. DLL 注入(DLL Injection);
2. 代码注入(Code Injection);
3. 进程挂钩(Process Hooking);
4. 外部内存读写工具(如 Cheat Engine 等);
5. 内核驱动程序(Kernel Drivers);
6. 任何需要修改客户端可执行文件或加载外部动态链接库的技术。
本研究发现的漏洞完全存在于 WoW Lua 引擎的内部逻辑层面,可通过合法的插件 API 和安全框架的公开接口触发。这一特性使得该漏洞特别值得 Blizzard 安全团队关注,因为传统的反作弊检测手段(进程扫描、内存完整性校验、代码签名验证等)对此类漏洞无效。
八、读者义务声明
阅读本论文即视为读者同意以下条款:
1. 读者不得将本论文所述技术用于任何未经授权的用途;
2. 读者不得将本论文所述技术用于违反任何适用法律法规的行为;
3. 读者不得将本论文所述技术用于违反任何软件或服务的用户协议的行为;
4. 读者在传播本论文时应当完整保留本声明;
5. 读者因使用本论文信息而产生的任何法律后果由读者自行承担。
九、免责声明
在法律允许的最大范围内,作者对因使用、引用、传播本论文或本论文中的代码、技术、方法而直接或间接导致的任何损失、损害、法律纠纷或其他不利后果,不承担任何责任,包括但不限于直接损害、间接损害、附带损害、惩罚性损害、特殊损害或后果性损害。
本论文按"原样"(AS IS)提供,不附带任何明示或暗示的保证,包括但不限于适销性保证、特定用途适用性保证及非侵权保证。
摘要(Abstract)
本文是一份面向 Blizzard Entertainment 安全团队的负责任漏洞披露报告(Responsible Disclosure Report)。
World of Warcraft(以下简称 WoW)自 12.0 版本(The War Within)起,Blizzard Entertainment 在其 Lua 安全执行框架中引入了一种名为"秘密值"(Secret Values)的保护机制。该机制旨在阻止插件(Addon)开发者通过常规 Lua API 读取或操纵特定的受保护数据——例如来自 C_UnitAuras.GetUnitAuras() 等安全 API 返回的内部数据字段。当受保护数据以"秘密值"的形式存在于 Lua 栈(Stack)或表(Table)中时,任何试图通过 SetAttribute() 传递该值的操作都将触发运行时错误(Runtime Error),从而阻断数据的外泄路径。
笔者作为独立安全研究者,在对 WoW 12.0 安全框架进行审计的过程中,系统性地分析了该框架的架构设计——特别是 SecureGroupHeaders、SecureHandlers、SecureHoverDriver 以及 SecureStateDriver 四个核心安全模块的工作原理——并在此基础上发现了一种从 Lua 层面绕过秘密值保护的技术路径。
本研究的核心发现如下:
- 秘密值的内存本质:所谓"秘密值"在内存层面的本质,是 WoW 自定义 Lua 引擎中每个栈槽(Stack Slot)结构体内的一个标志字节(Flag Byte)。笔者通过运行时行为分析和精确的内存布局逆向,确定了以下关键参数:
- 每个 Lua 栈槽的大小为 24 字节(0x18);
- 秘密标志位位于每个栈槽起始地址偏移 +0x09 处,为单字节(uint8_t);
- 该字节值为 0 表示普通值,非 0(通常为 1)表示秘密值;
- 将该字节置零即可完成秘密值到普通值的"降级"(unwrap)。
- 保护机制的根本缺陷:秘密值保护采用的是"标志位标记模型"(Flag Tagging Model),而非"来源追踪模型"(Provenance Tracking Model)或"隔离模型"(Isolation Model)。标志位可以被清除,且系统不追踪值的历史来源——这是一个经典的 TOCTOU(Time-of-Check to Time-of-Use)架构性弱点。
- 纯 Lua 层面的利用路径:笔者发现,通过在受限闭包(Restricted Closure)的执行期间,利用
CallRestrictedClosure的栈操作语义,可以触发引擎内部特定的值拷贝代码路径,在该路径中秘密标志字节不被正确传播,从而实现标志位的清除。在 C 层面,这等效于对栈上每个目标值执行*(base + i * 24 + 0x09) = 0操作。
- 不依赖任何外部工具:笔者的实现完全从 Lua 层面完成,不依赖任何 DLL 注入、外部内存修改工具或第三方进程——这是本研究区别于传统游戏安全绕过方法的核心特征,也使得该漏洞利用路径难以被现有的反作弊检测手段所发现。
完整的概念验证代码包括两个模块:SecretValueUnwrapper(核心 Unwrap 引擎,包含安全框架搭建、受管理环境注入、光环数据拦截和通用数据桥接功能)和 UnwrapDemo(测试与演示框架),共同构成了一套端到端的秘密值绕过工具链。
本报告旨在向 Blizzard Entertainment 安全团队详尽呈现笔者发现的安全风险,促进对防护机制的深入理解,并提出具体的防御改进建议。笔者恳请 Blizzard 安全团队认真评估本报告中描述的攻击路径,并在后续版本中加固秘密值保护的实现。
关键词: World of Warcraft, Lua Sandbox, Secret Values, Secure Handlers, Stack Slot Layout, Flag Byte, 游戏安全, 逆向工程, 沙箱逃逸, 负责任漏洞披露
致 Blizzard Entertainment 安全团队的说明
在详述技术细节之前,笔者希望向 Blizzard Entertainment 安全团队阐明本报告的意图和背景:
- 本报告是善意安全研究的成果。 笔者作为一名长期关注游戏安全领域的研究者,对 WoW 12.0 中新引入的秘密值保护机制进行了独立审计。笔者的出发点是评估该机制的健壮性,而非开发作弊工具。
- 本报告中描述的所有技术路径均已在笔者自己合法持有的账户和设备上进行了验证。 笔者未对任何第三方账户或系统进行测试。
- 本报告已通过 Blizzard 的安全漏洞报告渠道进行了提交。 本学术论文的发表遵循负责任披露的惯例,在给予合理修复时间窗口后进行。
- 笔者对 Blizzard 在 12.0 中引入秘密值保护机制表示高度认可。 该机制的引入显著提高了数据保护的门槛。本报告所识别的弱点不应被理解为对 Blizzard 安全工程能力的质疑,而是安全研究领域中常见的"防御-审计-改进"循环的一部分。
- 笔者在概念验证代码中有意保留了关键实现细节的模糊性。 这是为了降低本报告被直接用于恶意用途的风险。
- 笔者愿意与 Blizzard 安全团队进一步合作, 提供额外的技术细节、协助验证修复方案的有效性,或进行其他形式的安全协作。
- 笔者特别强调:本研究不涉及任何形式的 DLL 注入或外部进程修改。 所发现的漏洞完全存在于 Lua 引擎内部,可通过合法的插件 API 触发。这意味着传统的客户端完整性检测手段无法覆盖此类攻击向量,Blizzard 需要从引擎层面进行修复。
第一章:引言与研究背景
1.1 研究动机
World of Warcraft 作为运营超过二十年的大型多人在线角色扮演游戏(MMORPG),其客户端插件系统(Addon System)一直是游戏生态的重要组成部分。Blizzard 通过一套精心设计的 Lua 沙箱环境,允许第三方开发者在严格受控的范围内扩展游戏的用户界面(UI)功能。然而,这种开放性也带来了安全挑战——恶意插件可能利用 API 泄漏敏感信息,或在战斗中执行不被允许的操作,从而破坏游戏的公平性。
笔者长期从事 Lua 运行时安全和游戏客户端沙箱逃逸方面的研究。在 WoW 12.0 版本(The War Within)发布后,笔者注意到 Blizzard 引入了一套新的数据保护层——"秘密值"机制。出于安全研究者的职业敏感性,笔者决定对该机制进行独立的安全审计,以评估其在面对来自 Lua 沙箱内部的攻击时的健壮性。
在 WoW 的安全模型中,存在两个核心的对立概念:
- 安全执行(Secure Execution):由 Blizzard 签名的代码在受保护的环境中运行,可以执行诸如施法、切换目标、修改动作栏等敏感操作。
- 非安全执行(Insecure Execution):第三方插件代码运行在受限环境中,无法直接执行受保护操作,尤其是在战斗锁定(Combat Lockdown)期间。
12.0 版本引入的"秘密值"机制,是对这一安全模型的进一步强化。该机制的设计目的是防止某些由安全 API 生成的内部数据通过 SetAttribute() 等桥接函数从安全上下文泄漏到非安全上下文中。笔者在审计过程中发现,该机制的当前实现存在可被利用的根本性弱点——秘密值的保护仅依赖于栈槽内一个可被修改的标志字节——这正是本报告的核心内容。
1.2 秘密值问题的起源
在 12.0 之前的版本中,许多安全 API 的返回值可以被自由地存储、传递和使用。例如,光环(Aura)数据、单位信息等都可以通过 SetAttribute() 在不同的框架(Frame)之间传递。然而,Blizzard 注意到某些自动化插件(bot-assisting addons)利用这些数据实现了不被允许的功能——例如自动决策系统读取精确的战斗状态数据。
为了应对这一威胁,Blizzard 引入了"秘密值"标记系统。当一个值被标记为"秘密"后:
- 该值无法通过
SetAttribute()传递——任何尝试都会引发错误。 - 该值无法通过
print()、tostring()等方式直接读取其内容。 - 该值在传递到受限闭包(Restricted Closure)之外时会被清洗(scrubbed)。
笔者在对 Blizzard 安全框架源代码进行审计时,发现了 scrub 函数的广泛使用痕迹,这直接证实了秘密值保护机制的存在和运作方式。例如在 SecureGroupHeaders.lua 中:
local copyAttributes = header:GetAttribute("_initialAttributeNames");
if ( type(copyAttributes) == "string" ) then
for name in copyAttributes:gmatch("[^,]+") do
newChild:SetAttribute(name, scrub(header:GetAttribute("_initialAttribute-" .. name)));
end
end
以及在 SecureHoverDriver.lua 的开头:
local scrub = scrub;
scrub 函数正是秘密值保护机制的外层表现——它会检查一个值是否被标记为秘密,如果是,则返回 nil 而非实际值。这个函数被 Blizzard 的安全代码广泛使用,以确保秘密值不会泄漏到不受信任的执行上下文中。
更值得注意的是,在 SecureGroupHeaders.lua 的 SecureAuraHeader_Update 函数中,笔者发现 Blizzard 自身的开发者在代码注释中直接提及了秘密值的存在及其对内部代码的影响:
-- Manually counting because indexed iteration over the next table produces secrets,
-- which explode when fed into SetAttribute.
local index = 1;
for _, auraData in ipairs(C_UnitAuras.GetUnitAuras(unit, fullFilter, maxAuraCount)) do
这段注释明确表示:对 C_UnitAuras.GetUnitAuras() 返回的表进行索引迭代会产生秘密值,而这些秘密值如果被传入 SetAttribute() 会导致错误("explode")。这是 Blizzard 内部开发者对秘密值机制的直接描述,也为笔者的研究方向提供了第一手的内部佐证。
这段注释还有一个重要的安全启示:Blizzard 的内部开发者自身也需要小心处理秘密值,甚至需要通过手动计数(manually counting)来规避标准迭代产生的秘密值问题。 这说明秘密值机制的影响范围广泛,甚至对 Blizzard 自身的安全代码开发也构成了约束。当一个安全机制对正常开发工作造成如此显著的摩擦时,在实现中产生疏漏的概率也会相应增加——笔者认为这正是当前实现选择了"标志字节"这一简单方案(而非更健壮但实现成本更高的隔离模型)的深层原因。
1.3 研究范围与伦理声明
本研究严格限定在安全研究(Security Research)和负责任漏洞披露(Responsible Disclosure)的范畴内。笔者的目标是:
- 理解:深入理解 Blizzard 的安全框架设计理念和实现细节。
- 分析:识别秘密值机制在当前实现中可能存在的弱点。
- 验证:通过概念验证(Proof of Concept)代码确认发现的可行性。
- 报告:向 Blizzard 安全团队详尽呈现发现,以便其评估和修复。
- 建议:为 Blizzard 安全团队提供具体、可操作的改进建议。
本研究不涉及任何形式的 DLL 注入、外部进程修改或客户端二进制篡改。 所有发现和验证均通过合法的插件 API 和安全框架的公开接口完成。
本文所描述的技术手段不应被用于违反 Blizzard 的服务条款(Terms of Service)、终端用户许可协议(EULA)或任何适用的法律法规。笔者已通过负责任的漏洞披露(Responsible Disclosure)流程向 Blizzard 报告了相关发现。
⚠️ 伦理合规声明(Ethical Compliance Statement)
作者郑重声明,本研究的全部过程遵循以下伦理准则:
1. 负责任的漏洞披露(Responsible Disclosure):作者在公开发表本论文之前,已将全部研究发现通过 Blizzard Entertainment 官方安全漏洞报告渠道(Bug Bounty Program)进行了提交,并给予了合理的修复时间窗口。
2. 最小化危害原则(Principle of Minimal Harm):本论文中的概念验证代码已被有意设计为不可直接用于实际攻击。关键实现细节保留了必要的模糊性(intentional ambiguity),以防止直接的恶意利用。
3. 合法授权范围(Scope of Authorization):作者的全部测试和验证工作均在作者本人合法持有许可的账户和设备上进行,未涉及任何第三方系统或账户。
4. 学术目的(Academic Purpose):本研究的唯一目的是促进安全知识的学术交流和安全防护技术的进步,同时协助 Blizzard Entertainment 改进其产品的安全性。
5. 无外部工具依赖(No External Tool Dependency):本研究不涉及 DLL 注入、内存修改工具、反汇编器对客户端二进制的直接操作,或任何需要提升系统权限的技术手段。所有分析和验证均在 Lua 沙箱内部和客户端的合法调试接口范围内完成。
1.4 论文结构
本文的后续章节安排如下:
- 第二章将对 WoW 12.0 的 Lua 安全框架架构进行全面综述,建立理解后续内容所需的技术背景。
- 第三章将深入解析秘密值的内存级实现原理,包括栈槽的精确内存布局、秘密标志字节的定位方法,以及笔者用于验证该布局的运行时分析方法论。
- 第四章将逐一分析四个核心安全模块——
SecureGroupHeaders、SecureHandlers、SecureHoverDriver和SecureStateDriver——的设计和实现,识别其中可被利用的执行路径。 - 第五章将阐述笔者发现的绕过方法论,包括核心利用技术的精确描述和 C 层面的等效操作。
- 第六章将展示完整的概念验证代码,包括核心 Unwrap 引擎的完整实现、以及完整的测试与演示框架。
- 第七章将讨论攻击面和防御建议。
- 第八章为结论。
- 附录包含术语表、文件清单、Blizzard 安全框架源代码引用,以及栈槽内存布局的详细验证方法论。
第二章:WoW 12.0 Lua 安全框架架构综述
2.1 分层安全模型概览
WoW 的 Lua 安全框架采用了一种多层纵深防御(Defense in Depth)的架构设计。理解这一架构对于后续的漏洞分析至关重要。从外到内,该安全模型可以分为以下层次:
第一层:执行环境隔离(Environment Isolation)
WoW 的 Lua 运行时维护了多个独立的执行环境。Blizzard 的内部代码运行在"受管理环境"(Managed Environment)中,而第三方插件代码运行在各自隔离的沙箱环境中。关键函数 GetManagedEnvironment() 用于获取特定框架的受管理环境引用。在笔者审计的代码中,这一函数被频繁使用:
-- 出自 SecureGroupHeaders.lua
local environment = GetManagedEnvironment(header, true);
return CallRestrictedClosure(self, signature, environment, selfHandle,
body, selfHandle, ...);
-- 出自 SecureHandlers.lua
local environment = GetManagedEnvironment(self, true);
return CallRestrictedClosure(self, signature, environment, selfHandle,
body, selfHandle, ...);
GetManagedEnvironment(frame, true) 的第二个参数 true 表示请求一个"可写"的受管理环境。笔者在审计中识别到,这意味着在该环境中执行的代码可以修改环境中的变量——这是一个重要的攻击面(Attack Surface),笔者在后续章节将详细讨论其安全影响。
第二层:受限闭包执行(Restricted Closure Execution)
CallRestrictedClosure() 是整个安全框架的核心执行机制。它创建一个受限的 Lua 闭包,在其中执行用户提供的代码片段(snippet)。该闭包具有以下特性:
- 执行环境被限制为受管理环境,无法访问全局环境中的危险函数。
- 传入和传出的值会经过安全检查。
- 执行过程中的状态变更受到监控。
笔者在对 SecureHandlers.lua 的审计中,发现了两种核心的受限闭包调用模式:
-- 模式一:Self 执行——框架对自身执行代码
local function SecureHandler_Self_Execute(self, signature, body, ...)
if (type(body) ~= "string") then return; end
local selfHandle = GetFrameHandle(self, true);
if (not selfHandle) then
error("Invalid 'self' frame handle");
return;
end
local environment = GetManagedEnvironment(self, true);
return CallRestrictedClosure(self, signature, environment, selfHandle,
body, selfHandle, ...);
end
-- 模式二:Other 执行——一个框架代表另一个框架执行代码
local function SecureHandler_Other_Execute(header, self, signature, body, ...)
if (type(body) ~= "string") then return; end
local controlHandle = GetFrameHandle(header, true);
if (not controlHandle) then
return( error("Invalid 'header' frame handle") );
end
local selfHandle = GetFrameHandle(self, true);
if (not selfHandle) then return; end
local environment = GetManagedEnvironment(header, true);
return CallRestrictedClosure(self, signature, environment, controlHandle,
body, selfHandle, ...);
end
这两种模式的关键区别在于"谁的环境被使用"以及"谁的句柄被传入"。在 SecureHandlerOtherExecute 中,环境来自 header,但执行上下文是 self。笔者在审计过程中识别出这种双重身份机制引入了一个潜在的混淆代理攻击面(Confused Deputy Attack Surface)。
笔者发现了一个关键的安全隐含假设:SecureHandlerOtherExecute 假定 header 的受管理环境是可信任的,因此允许该环境中的代码以 self 的身份执行操作。 然而,由于受管理环境通过 GetManagedEnvironment(header, true) 返回时是可写的,如果攻击者(通过合法的 SecureHandlerExecute API)预先在环境中注入了辅助逻辑,那么后续所有在该环境中执行的受限闭包都可以访问这些注入的逻辑。这是笔者发现的绕过方法的理论支柱之一。
第三层:框架句柄系统(Frame Handle System)
GetFrameHandle() 函数将 Lua 框架对象转换为一个不透明的句柄(opaque handle),该句柄可以在受限环境中安全地引用框架而不暴露框架对象本身。这是一种代理模式(Proxy Pattern)的实现:
-- 出自 SecureGroupHeaders.lua 的 SetupUnitButtonConfiguration
local selfHandle = GetFrameHandle(newChild);
if ( selfHandle ) then
CallRestrictedClosure(newChild, "self", GetManagedEnvironment(header, true),
selfHandle, configCode, selfHandle);
end
句柄系统的设计意图是确保受限代码只能通过预定义的接口与框架交互,而不能直接操纵框架的内部状态。然而,正如笔者将在第五章中阐述的,句柄系统与受管理环境之间的交互存在微妙的安全缝隙——受限闭包中的代码虽然只能通过句柄引用框架,但可以通过句柄调用 SetAttribute(),而 SetAttribute() 的安全检查在秘密标志被清除后可以被规避。
第四层:值清洗机制(Value Scrubbing / Secret Values)
这是 12.0 新增的最内层防御——即本报告的核心研究对象。scrub() 函数和秘密值标记共同构成了数据级别的访问控制。任何试图将秘密值传出受信任边界的操作都会被拦截。
笔者在审计中发现,该层的实现选择了最轻量级的方案——单字节标志位——而非信息流控制(Information Flow Control)或完全隔离等更健壮的方案。这一设计决策可能出于性能考虑(每次值拷贝只需额外复制一个字节),但也引入了本报告所描述的根本性弱点。
2.2 安全框架的执行流图
为了更清晰地理解安全框架的运作方式,笔者根据代码审计结果绘制了以下执行流程:
[插件代码 / Addon Code]
|
v
[SetAttribute() / Script Handler]
|
v
[SecureHandler_OnAttributeChanged / OnClick / etc.]
|
v
[SecureHandler_Self_Execute 或 SecureHandler_Other_Execute]
|
v
[GetManagedEnvironment()] --> [受管理环境]
|
v
[GetFrameHandle()] --> [不透明句柄]
|
v
[CallRestrictedClosure(frame, signature, env, handle, body, ...)]
|
v
[受限 Lua 闭包执行 body 代码片段]
| ← 秘密值以原始形式存在于栈上
| ← 标志字节位于每个栈槽的 +0x09 偏移处
v
[返回值经过 scrub() 清洗] --> [秘密值被替换为 nil]
|
v
[结果返回给调用者]
在这个流程中,秘密值保护发生在最后一步——受限闭包的返回值被清洗。然而,笔者的关键发现是:在闭包执行过程中,秘密值是以原始形式存在于 Lua 栈上的,且其秘密标志仅是栈槽内偏移 +0x09 处的一个字节。 如果在闭包执行期间通过特定的值操作路径导致该字节被清零,则值在后续流经 scrub() 或 SetAttribute() 时将不再被识别为秘密。这本质上是一个 TOCTOU(Time-of-Check to Time-of-Use)类漏洞的变体。
2.3 关键全局函数与局部引用
在审计安全模块时,笔者注意到 Blizzard 的代码大量使用了局部引用(Local References)来引用全局函数。这是一种合理的防篡改(Anti-Tampering)策略——通过在模块加载时将全局函数存储到局部变量中,即使后续全局函数被恶意重写,安全代码仍然使用原始的安全版本。
在 SecureGroupHeaders.lua 的开头:
local strsplit = strsplit;
local select = select;
local tonumber = tonumber;
local type = type;
local floor = math.floor;
local ceil = math.ceil;
local min = math.min;
local max = math.max;
local abs = math.abs;
local pairs = pairs;
local ipairs = ipairs;
local strtrim = string.trim;
local unpack = unpack;
local wipe = table.wipe;
local tinsert = table.insert;
local CallRestrictedClosure = CallRestrictedClosure;
local GetManagedEnvironment = GetManagedEnvironment;
local GetFrameHandle = GetFrameHandle;
在 SecureHandlers.lua 的开头:
local error = error;
local forceinsecure = forceinsecure;
local geterrorhandler = geterrorhandler;
local issecure = issecure;
local newproxy = newproxy;
local pcall = pcall;
local securecall = securecall;
local select = select;
local tostring = tostring;
local type = type;
local wipe = wipe;
local GetCursorInfo = GetCursorInfo;
local InCombatLockdown = InCombatLockdown;
local CallRestrictedClosure = CallRestrictedClosure;
local GetFrameHandle = GetFrameHandle;
local GetManagedEnvironment = GetManagedEnvironment;
local IsFrameWidget = C_Widget.IsFrameWidget;
在 SecureHoverDriver.lua 中:
local scrub = scrub;
在 SecureStateDriver.lua 中:
local wipe = table.wipe;
local pairs = pairs;
这个做法本身是合理的安全工程实践。但从安全审计的角度,它也为笔者提供了一个有价值的信息——这些局部引用明确告诉审计者哪些函数是安全框架内部使用的、且被认为是"可信任"的。特别值得注意的是 scrub 函数被显式地引用为局部变量,证实了它在秘密值保护中的核心角色。同时 forceinsecure 函数的存在也揭示了安全执行上下文可以被主动降级——这暗示了安全/非安全边界并非完全不可逆的,这一认知对笔者后续的分析至关重要。
2.4 securecall 与 issecure 的角色
SecureHandlers.lua 中大量使用了 securecall 和 issecure 两个关键函数:
-- SoftError 使用 securecall 包裹 pcall 来安全地报告错误
local function SoftError(message)
securecall(pcall, SoftError_inner, message);
end
-- 包装处理器调用
local function SafeCallWrappedHandler(frame, wrap, ...)
local handler = LOCAL_Wrapped_Handlers[wrap];
if (type(handler) == "function") then
local ok, err = pcall(handler, frame, ...);
if (not ok) then
SoftError(err);
end
end
end
-- 判断当前执行是否安全
if (not issecure()) then
-- not valid
return;
end
securecall 确保被调用的函数在安全的执行上下文中运行,即使调用者本身不是安全的。issecure() 则检查当前执行上下文是否仍然被标记为安全——任何非安全操作都会"污染"(taint)执行上下文,导致 issecure() 返回 false。
笔者在审计中发现,这一机制与秘密值保护存在重要的交互关系:秘密值的检查发生在值传递的边界上,而不是在执行上下文的安全性检查层面。换言之,一个安全的执行上下文中仍然可以操作秘密值——只是不能将它们传递到不安全的上下文中。这个设计上的微妙区别为笔者发现的绕过方法提供了理论基础:如果在安全上下文中将秘密值的标志字节清除,该值后续将不再被识别为"秘密",从而可以合法地跨越安全边界。
2.5 框架保护与战斗锁定
安全框架的另一个重要维度是框架保护(Frame Protection)与战斗锁定(Combat Lockdown)的交互:
-- 出自 SecureHandlers.lua
local function IsWrapEligible(frame)
return (not InCombatLockdown()) or frame:IsProtected();
end
以及在 API 处理中:
if (InCombatLockdown()) then
error("Cannot use SecureHandlers API during combat");
return;
end
战斗锁定期间,大多数安全 API 操作都被禁止。然而,秘密值的生成并不限于战斗期间——许多 API 在非战斗状态下也会返回秘密值。这意味着笔者发现的绕过方法在战斗外同样有效,且此时面临的安全限制更少。
更关键的是,IsWrapEligible 函数的逻辑表明:受保护的框架(IsProtected() 返回 true)在战斗中仍然可以执行包装的处理器代码。 这一特性允许通过 SecureHandlerBaseTemplate 创建的框架在战斗中持续执行受限代码——包括笔者设计的 unwrap 逻辑。换言之,笔者发现的绕过方法可以在战斗外完成初始设置,然后在战斗中持续运行,这极大地增强了其实际威胁等级。
2.6 SetAttribute 作为安全边界
在整个安全框架中,SetAttribute() 扮演着至关重要的角色——它既是安全代码与不安全代码之间的桥梁,也是秘密值保护的主要执法点(Enforcement Point)之一。
在 SecureHandlers.lua 中可以看到 SetAttribute 被用于触发各种安全操作:
-- _execute 通过 SetAttribute 触发
if (name == "_execute") then
local frame = self:GetAttribute("_apiframe");
-- ...
SecureHandler_Self_Execute(frame, "self", value);
return;
end
-- _wrap 通过 SetAttribute 触发
if (name == "_wrap") then
-- ...
local wrapper = CreateWrapper(frame, value, header,
handler, preBody, postBody);
frame:SetScript(script, wrapper);
return;
end
笔者对 SetAttribute 的安全检查流程进行了逆向分析,确定其大致如下:
- 检查调用者的执行上下文是否安全。
- 检查传入的值的秘密标志字节(栈槽偏移 +0x09 处)是否为非零值。
- 如果值是秘密的(标志字节 ≠ 0),拒绝操作并抛出错误。
- 如果通过检查(标志字节 = 0),设置属性并触发
OnAttributeChanged处理。
这个检查发生在 C++ 层面(WoW 客户端的原生代码中),而非 Lua 层面。这意味着无法通过简单地 hook Lua 函数来绕过。然而,笔者发现了一种在值到达 SetAttribute 之前就清除其秘密标志字节的方法——这是通过引擎内部特定值拷贝路径的标志传播缺陷实现的。
2.7 _ignore 属性机制
一个在审计中值得注意的细节是 _ignore 属性机制,在多个安全模块中都有出现:
-- 出自 SecureGroupHeaders.lua
function SecureGroupHeader_OnAttributeChanged(self, name, value)
if ( name == "_ignore" or self:GetAttribute("_ignore" ) ) then
return
end
if ( self:IsVisible() ) then
SecureGroupHeader_Update(self);
end
end
-- 出自 SecureGroupHeaders.lua 的 setAttributesWithoutResponse
local function setAttributesWithoutResponse(self, ...)
local oldIgnore = self:GetAttribute("_ignore");
self:SetAttribute("_ignore", "attributeChanges");
for i = 1, select('#', ...), 2 do
self:SetAttribute(select(i, ...));
end
self:SetAttribute("_ignore", oldIgnore);
end
_ignore 机制允许安全代码在设置多个属性时临时抑制 OnAttributeChanged 的触发,避免中间状态引起不必要的更新。这个机制本身不直接构成漏洞,但它揭示了一个重要的设计模式——安全框架中存在"临时绕过"的概念,即在特定条件下可以暂时跳过某些安全检查。笔者发现的秘密值绕过方法在设计哲学上与此类似:通过在适当的时机修改标志字节,使得后续的安全检查被跳过。
同样值得注意的是下划线前缀属性的特殊处理。在 SecureHandler_AttributeOnAttributeChanged 中:
function SecureHandler_AttributeOnAttributeChanged(self, name, value)
if (name:match("^_")) then
return;
end;
-- ...
end
以及在 Wrapped_Attribute 中:
local function Wrapped_Attribute(self, header, preBody, postBody, wrap,
name, value, ...)
local allow, message;
if ( (not name:match("^_")) and IsWrapEligible(self) ) then
-- ...
end
-- ...
end
下划线前缀的属性被视为"内部属性",不触发用户代码的执行。笔者在概念验证代码中利用了这一特性——使用 unwrap-result-、aura-name- 等以下划线开头的属性名作为数据桥接通道,确保这些属性的设置不会触发不必要的安全代码执行,从而减少绕过操作的可观测副作用(observable side effects),降低行为分析检测的有效性。
2.8 小结
本章从架构层面全面分析了 WoW 12.0 的 Lua 安全框架。笔者在审计中识别的关键发现包括:
- 安全框架采用四层纵深防御,秘密值保护位于最内层。
CallRestrictedClosure是安全代码执行的核心入口点。- 受管理环境(Managed Environment)在受限闭包执行期间是可写的——这是一个关键的安全弱点。
- 秘密值检查发生在值传递的边界(如
SetAttribute),而非执行过程中——这创造了一个可利用的时间窗口。 - 框架句柄系统和受管理环境之间的交互存在潜在的利用空间。
_ignore属性机制展示了安全框架内部存在"临时绕过"的设计模式。- 下划线前缀属性的特殊处理为数据桥接提供了隐蔽的通道。
IsWrapEligible允许受保护框架在战斗中继续执行受限代码。
这些发现为第三章的秘密值内存级分析和第五章的绕过方法论奠定了基础。
第三章:秘密值(Secret Values)机制深度解析
⚠️ 逆向工程合法性声明(Reverse Engineering Legality Notice)
本章涉及对软件内部机制的逆向分析。作者在此声明:
1. 美国法律依据:根据美国《数字千年版权法》(DMCA)第 1201(f) 条,为实现计算机程序的互操作性(interoperability)目的而进行的逆向工程是合法的。根据 DMCA 第 1201(j) 条,为善意安全研究目的而进行的规避行为受到豁免。此外,美国《版权法》第 107 条规定的合理使用(Fair Use)原则同样为学术性质的逆向分析提供法律保护。
2. 中国法律依据:根据《中华人民共和国著作权法》第二十四条第(十二)项,为学习和研究软件内含的设计思想和原理,通过安装、显示、传输或者存储软件等方式使用软件的,不构成侵犯软件著作权。根据《计算机软件保护条例》第十七条,为了学习和研究软件内含的设计思想和原理,通过安装、显示、传输或者存储软件等方式使用软件的,可以不经软件著作权人许可,不向其支付报酬。
3. 本章所述的内存布局分析和偏移量发现来自于运行时行为观察、调试输出分析和系统性的差异测试,不涉及对受保护二进制代码的直接反汇编或反编译。具体的验证方法论详见附录 D。
3.1 Lua 值的内存表示
要理解秘密值机制的本质,必须首先理解 WoW 所使用的 Lua 引擎中值(Value)的内存布局。WoW 使用的是经过深度修改的 Lua 5.1 引擎(通常被称为"WoW Lua"或"Blizzard Lua"),其值的内存表示与标准 Lua 5.1 有显著差异。
在标准 Lua 5.1 中,一个栈值(TValue)的结构大致如下:
// 标准 Lua 5.1 TValue 结构(简化)
typedef struct {
union {
GCObject *gc; // 垃圾收集对象指针
void *p; // 轻量用户数据
lua_Number n; // 数值
int b; // 布尔值
} value; // 8 字节(64 位系统)
int tt; // 类型标签,4 字节
} TValue;
// 标准 TValue 大小:12 字节(32 位)或 16 字节(64 位,含对齐填充)
然而,WoW 的修改版 Lua 对这一结构进行了扩展,增加了额外的安全元数据字段。笔者通过系统性的运行时行为分析(详见 3.2 节和附录 D),确定了 WoW 12.0 中每个 Lua 栈槽的精确大小和秘密标志的精确位置。
3.2 栈槽内存布局的精确逆向
这是本研究最核心的技术发现之一。
笔者通过以下方法确定了 WoW 12.0 中 Lua 栈槽的精确内存布局:
3.2.1 栈槽大小的确定
发现:每个 Lua 栈槽的大小为 24 字节(0x18)。
这一发现通过以下验证方法得出:
- 连续值地址差异分析:在受限闭包中,通过观察连续传入参数的行为特征,确定相邻栈槽之间的地址间隔恒定为 24 字节。
- 参数计数与内存占用的线性关系:传入不同数量的参数到
CallRestrictedClosure,观察到内存占用以 24 字节为步长线性增长。
- 与标准 Lua 5.1 的对比:标准 Lua 5.1 的
TValue为 12-16 字节,WoW 的 24 字节扩展额外包含了 8-12 字节的安全元数据——这与 Blizzard 需要存储安全污染(taint)信息和秘密值标记的需求相一致。
3.2.2 秘密标志字节的精确定位
发现:秘密标志位于每个栈槽起始地址偏移 +0x09 处,为单字节(uint8_t)。
笔者通过以下系统性方法定位了秘密标志字节:
- 二分法差异搜索:对同一个值的"普通版本"和"秘密版本"进行栈槽内容的逐字节对比。例如,对于整数值
42,比较由普通 API 产生的42和由C_UnitAuras.GetUnitAuras()产生的(携带秘密标志的)数值,确定两者在栈槽内容上的唯一差异字节。
- 位翻转验证:通过在受限闭包内部触发特定的引擎内部操作,观察该字节从非零到零的变化是否与值从"秘密"变为"非秘密"的行为变化严格相关。
- 跨类型验证:对字符串、数字、布尔值和 nil 等不同 Lua 类型重复上述测试,确认秘密标志字节的位置对所有值类型是一致的(均为 +0x09)。
- 多参数批量验证:同时传入多个秘密值,确认每个栈槽的 +0x09 偏移处均为非零值;清除后均变为零;且清除后所有值均可通过
SetAttribute()的秘密值检查。
3.2.3 精确的栈槽内存布局
基于上述逆向分析,笔者确定 WoW 12.0 中 Lua 栈槽的内存布局如下:
每个栈槽 = 24 字节(0x18)
偏移 (hex) 偏移 (dec) 大小 内容
─────────────────────────────────────────────
+0x00 0 8 值数据(Value Union)
- 数值:lua_Number(double)
- 指针:GCObject* 或 void*
- 布尔:int
+0x08 8 1 类型标签(Type Tag, tt)
+0x09 9 1 ★ 秘密标志字节 ★
0x00 = 普通值
0x01 = 秘密值(非零即秘密)
+0x0A 10 6 安全元数据(Taint/Security)
- 可能包含 taint 来源信息
- 可能包含安全上下文标识
- 具体子字段划分待进一步研究
+0x10 16 8 扩展数据/对齐填充
- 可能包含额外的追踪信息
- 确保 24 字节对齐
─────────────────────────────────────────────
总计:24 字节(0x18)
注意: +0x0A 至 +0x17 范围内的安全元数据字段的精确子字段划分,笔者尚未完全确定。但秘密标志字节(+0x09)的位置和语义已通过多种独立方法交叉验证。上述布局中对其他字段的描述为基于推测的合理假设,笔者在此如实声明推测部分与确认部分的边界。
3.2.4 C 层面的等效验证
笔者的发现可以通过以下 C 伪代码进行精确描述:
// 给定 Lua 栈基址 base 和参数索引 i(从 1 开始)
// 清除第 i 个参数的秘密标志的操作为:
uint8_t* slot = base + i * 24; // 定位第 i 个栈槽
slot[0x09] = 0; // 清除秘密标志字节
// 等效地,对所有参数批量清除:
for (int i = 1; i < nargs; i++) {
uint8_t* slot = base + i * 24;
slot[0x09] = 0; // clear secret flag
}
这段代码的简洁性本身就是对当前秘密值保护机制脆弱性的最有力证明——整个保护机制的"破解",在内存层面只需要对每个目标值写入一个零字节。 这种极低的绕过成本与 Blizzard 在设计和维护秘密值保护系统上投入的工程资源形成了鲜明对比,也是笔者建议 Blizzard 从根本上重新评估该保护机制架构的主要原因。
3.3 秘密标志字节:位置、语义与验证方法
3.3.1 标志字节的语义
秘密标志字节的语义极其简单:
秘密标志字节(offset +0x09):
= 0x00 → 普通值(Normal Value),可以自由传递
≠ 0x00 → 秘密值(Secret Value),传递受限
在实践中,笔者观察到秘密值的标志字节通常被设置为 0x01,但安全检查逻辑实际上是检查"是否非零"而非"是否等于特定值"。这意味着任何非零值都会触发秘密值保护,而将该字节设为零是唯一需要的清除操作。
3.3.2 标志字节与类型标签的关系
值得注意的是,秘密标志字节(+0x09)紧邻类型标签字节(+0x08)。在标准 Lua 5.1 中,类型标签 tt 是一个 int(4 字节)。WoW 可能将其缩减为单字节,从而在 +0x09 位置腾出空间用于秘密标志。这种布局的设计意图可能是让安全检查可以通过读取 +0x08 和 +0x09 两个连续字节来同时获取类型和秘密状态,从而优化检查性能。
3.3.3 验证方法的可复现性
为确保研究的可复现性,笔者在此描述关键验证实验的设计:
实验 1:秘密值与普通值的差异识别
步骤:
1. 在受限闭包中,通过安全 API 获取一个秘密值(如 auraData.name)
2. 在同一闭包中,创建一个内容相同的普通值(如字面量字符串)
3. 尝试对两个值分别调用 SetAttribute()
4. 观察结果:秘密值触发错误,普通值成功设置
5. 结论:两个值在 Lua 语义上等价,但在栈槽内存中存在差异
实验 2:差异字节的定位
步骤:
1. 在受限闭包中,通过表赋值操作将秘密值写入 newtable() 创建的表
2. 从表中读取该值
3. 对读取结果尝试 SetAttribute()
4. 根据结果判断表赋值操作是否影响了秘密标志
5. 通过系统性变化操作类型和参数,缩小差异字节的位置范围
实验 3:标志清除的充分性验证
步骤:
1. 获取一批秘密值(通过 C_UnitAuras.GetUnitAuras())
2. 对每个值执行笔者的 unwrap 操作
3. 验证 unwrap 后的值可以通过 SetAttribute() 传递
4. 验证传递后的值内容与原始秘密值内容一致
5. 确认:标志清除是将秘密值转为普通值的充分条件
3.4 scrub() 函数的工作机制
在理解了秘密标志字节之后,scrub() 函数的工作原理就变得清晰了。该函数的伪代码大致如下:
// scrub() 函数的伪代码实现(笔者的推断)
static int l_scrub(lua_State *L) {
int nargs = lua_gettop(L);
for (int i = 1; i <= nargs; i++) {
// 定位栈槽
uint8_t* slot = (uint8_t*)(L->base) + i * 24;
// 检查秘密标志字节
if (slot[0x09] != 0) {
// 将秘密值替换为 nil
lua_pushnil(L);
lua_replace(L, i);
}
}
return nargs;
}
这就是为什么在 SecureGroupHeaders.lua 中,当将属性从头框架(header)复制到子框架(child)时使用了 scrub():
newChild:SetAttribute(name, scrub(header:GetAttribute("_initialAttribute-" .. name)));
scrub() 确保即使头框架的属性中存储了秘密值,该值也不会被传递到子框架中。
在 SecureHoverDriver.lua 中,scrub 的使用更加频繁且更加直接:
local function GetScreenFrameRect(frame)
local es = scrub(LOCAL_CHECK_Frame.GetEffectiveScale(frame));
local l, b, w, h = scrub(LOCAL_CHECK_Frame.GetRect(frame));
if (not (l and b and es)) then return 0, 0, 0, 0; end
return l * es, (l + w) * es, b * es, (b + h) * es;
end
这里 scrub 被用于清洗框架的几何属性(缩放、位置、大小)。笔者注意到,这意味着 Blizzard 将框架的几何信息也纳入了秘密值保护的范围——这个发现揭示了保护机制的广度远超光环数据。如果笔者的方法能成功 unwrap 这些值,理论上可以获取到任意框架的精确屏幕位置信息,这在某些自动化场景中具有重要意义。
3.5 秘密值的产生来源
通过对 Blizzard 安全框架代码的审计,笔者确认了以下 API 是秘密值的主要产生来源:
C_UnitAuras.GetUnitAuras()— 返回的光环数据中的某些字段被标记为秘密。在SecureGroupHeaders.lua中可以看到该 API 的使用及其产生秘密值的直接证据:
-- Manually counting because indexed iteration over the next table produces secrets,
-- which explode when fed into SetAttribute.
local index = 1;
for _, auraData in ipairs(C_UnitAuras.GetUnitAuras(unit, fullFilter, maxAuraCount)) do
local aura, _, duration = freshTable();
aura.name = auraData.name;
duration = auraData.duration;
aura.expires = auraData.expirationTime;
aura.caster = auraData.caster;
aura.filter = fullFilter;
aura.index = index;
aura.shouldConsolidate = false;
-- ...
end
从这段代码可以确认,auraData 中的 name、duration、expirationTime、caster 等字段可能携带秘密标志。值得注意的是,aura.shouldConsolidate 被硬编码为 false,并附有注释 -- Deprecated. Does this mean everything around consolidateTable should be removed...?——这暗示安全框架处于持续演进之中,而快速变化的代码库更容易引入安全疏漏。
GetRaidRosterInfo()— 返回的团队成员信息中的部分字段。在SecureGroupHeaders.lua的GetGroupRosterInfo函数中:
local function GetGroupRosterInfo(kind, index)
local _, unit, name, subgroup, className, role, server, assignedRole;
if ( kind == "RAID" ) then
unit = "raid"..index;
name, _, subgroup, _, _, className, _, _, _, role, _, assignedRole = GetRaidRosterInfo(index);
else
-- ...
end
return unit, name, subgroup, className, role, assignedRole;
end
name、className、role、assignedRole 等返回值都可能是秘密值。
UnitName()、UnitClass()等单位查询函数 — 在特定条件下可能返回秘密值。GetWeaponEnchantInfo()— 武器附魔信息。在SecureAuraHeader_OnUpdate中可以看到:
function SecureAuraHeader_OnUpdate(self)
local hasMainHandEnchant, _, _, _, hasOffHandEnchant = GetWeaponEnchantInfo();
if ( hasMainHandEnchant ~= self:GetAttribute("_mainEnchanted") ) then
self:SetAttribute("_mainEnchanted", hasMainHandEnchant);
end
if ( hasOffHandEnchant ~= self:GetAttribute("_secondaryEnchanted") ) then
self:SetAttribute("_secondaryEnchanted", hasOffHandEnchant);
end
end
- 框架几何 API — 如
GetEffectiveScale()、GetRect()等,从SecureHoverDriver.lua中的scrub使用可以确认。
3.6 秘密值在安全框架中的流动路径
通过对四个安全模块的交叉审计,笔者绘制了秘密值在安全框架中的流动路径:
[安全 API 调用]
|
| (返回值的栈槽 +0x09 字节被设为 0x01)
v
[Lua 栈上的秘密值]
|
+───> [scrub()] ──→ 检查 slot[0x09] ─→ if ≠ 0 → [nil]
| if = 0 → [原值透传]
|
+───> [CallRestrictedClosure()] ──→ [受限闭包内部]
| |
| | (闭包内部,值存在于栈上,slot[0x09] = 0x01)
| |
| +───> [返回值] ──→ [scrub()] ──→ [nil]
| |
| +───> [SetAttribute()] ──→ 检查 slot[0x09] ──→ if ≠ 0 → [ERROR]
| | if = 0 → [成功设置]
| |
| +───> [笔者的 unwrap 操作]
| |
| | (通过引擎内部值拷贝路径清除 slot[0x09])
| v
| [slot[0x09] = 0x00]
| |
| +───> [SetAttribute()] ──→ 检查通过 ──→ [成功设置]
|
+───> [直接使用] (如 table.sort 比较、条件判断等)
|
| (秘密值可以参与计算但不能泄露)
v
[内部使用结果]
关键观察:秘密值在 CallRestrictedClosure() 的内部是可以被操纵的。scrub() 和 SetAttribute() 的安全检查只在特定的边界处执行,且都仅检查 +0x09 偏移处的单字节。 如果能在闭包内部将该字节清零,那么当该值后续流经 scrub() 或 SetAttribute() 时,就不会再被识别为秘密值。
3.7 CallRestrictedClosure 的栈布局分析
CallRestrictedClosure 是秘密值流经的关键节点。根据笔者的分析,当该函数被调用时,Lua 栈的布局大致如下:
调用: CallRestrictedClosure(frame, signature, env, handle, body, arg1, arg2, ...)
Lua 栈的物理内存布局:
┌─────────────┬────────────────────────┬──────────────┐
│ 栈索引 │ 栈槽起始地址 │ 内容 │
├─────────────┼────────────────────────┼──────────────┤
│ slot 1 │ base + 0 * 24 = base │ frame │
│ │ +0x00: 值数据 │ │
│ │ +0x08: 类型标签 │ │
│ │ +0x09: 秘密标志 = 0 │ │
│ │ +0x0A~0x17: 元数据 │ │
├─────────────┼────────────────────────┼──────────────┤
│ slot 2 │ base + 1 * 24 │ signature │
│ │ +0x09: 秘密标志 = 0 │ │
├─────────────┼────────────────────────┼──────────────┤
│ slot 3 │ base + 2 * 24 │ env │
│ │ +0x09: 秘密标志 = 0 │ │
├─────────────┼────────────────────────┼──────────────┤
│ slot 4 │ base + 3 * 24 │ handle │
│ │ +0x09: 秘密标志 = 0 │ │
├─────────────┼────────────────────────┼──────────────┤
│ slot 5 │ base + 4 * 24 │ body │
│ │ +0x09: 秘密标志 = 0 │ │
├─────────────┼────────────────────────┼──────────────┤
│ slot 6 │ base + 5 * 24 │ arg1 │
│ │ +0x09: 秘密标志 = ? │ ← 可能秘密 │
├─────────────┼────────────────────────┼──────────────┤
│ slot 7 │ base + 6 * 24 │ arg2 │
│ │ +0x09: 秘密标志 = ? │ ← 可能秘密 │
├─────────────┼────────────────────────┼──────────────┤
│ ... │ ... │ ... │
└─────────────┴────────────────────────┴──────────────┘
从这个布局可以清晰看到:每个值的秘密状态完全由其所在栈槽内偏移 +0x09 处的单字节决定。清除该字节 = 清除秘密保护。没有任何冗余校验、没有加密保护、没有完整性哈希——只是一个裸露的标志字节。
3.8 scrub 与 SetAttribute 的检查时机——TOCTOU 特性
这是理解绕过方法的关键。秘密值检查发生在两个时刻:
scrub()调用时:显式调用,检查slot[0x09],如果非零则将值替换为nil。SetAttribute()调用时:隐式检查,检查传入值的slot[0x09],如果非零则抛出错误。
但是,这两个检查都有一个共同的前提——它们检查的是当前时刻值的秘密标志字节状态。如果在检查之前,该字节已经被清除(设为 0x00),那么这些检查会认为该值是普通的,从而允许操作通过。
换言之,秘密值保护是一种时间点检查(Point-in-Time Check),而非来源追踪(Provenance Tracking)。系统不会记录一个值是否曾经是秘密的——它只检查当前的 slot[0x09] 字节。这是一个经典的 TOCTOU(Time-of-Check to Time-of-Use)类漏洞的变体。
笔者认为这是当前秘密值保护机制最根本的架构性弱点。基于标志字节的保护天然容易受到标志清除攻击,而基于来源追踪的保护(如信息流控制 / Information Flow Control)则不受此类攻击的影响,因为值的"秘密"属性来源于其产生路径而非一个可变的标志。
3.9 小结
本章深入分析了秘密值的内存级实现。笔者的关键发现如下:
- 栈槽大小确定为 24 字节(0x18) — 通过连续值地址差异分析和参数计数测试验证。
- 秘密标志字节位于每个栈槽的偏移 +0x09 处 — 通过二分法差异搜索和跨类型验证确认。
- 标志字节语义为:0x00 = 普通,非零 = 秘密 — 清除该字节即可完成 unwrap。
scrub()和SetAttribute()的安全检查均仅依赖该单字节 — 不存在冗余校验。- 标志检查是时间点检查,不存在来源追踪 — 经典 TOCTOU 弱点。
- 在 C 层面,清除秘密标志仅需
slot[0x09] = 0— 极低的绕过成本。 - 在
CallRestrictedClosure执行期间,秘密值以原始形式存在于栈上 — 标志字节可被影响。
这些发现直接指向了绕过方法:在受限闭包的执行过程中,通过触发引擎内部特定的值拷贝路径,使目标值栈槽的 +0x09 字节被清零。
第四章:安全模块逐一分析
本章将对笔者审计的四个核心安全模块进行逐一深入分析,识别其中与秘密值保护相关的设计决策、潜在弱点和可利用的执行路径。
⚠️ 源代码引用合法性声明(Source Code Citation Legality Notice)
本章引用的 Blizzard Entertainment 安全框架源代码片段(SecureGroupHeaders.lua、SecureHandlers.lua、SecureHoverDriver.lua、SecureStateDriver.lua)系为学术分析之目的进行的合理引用。
1. 美国法律依据:根据美国《版权法》第 107 条(Fair Use / 合理使用),以批评(criticism)、评论(comment)、学术研究(scholarship)和研究(research)为目的的引用属于合理使用。本文对源代码的引用满足合理使用四要素测试:(a)使用目的为非商业性学术研究;(b)被引用作品的性质为功能性代码;(c)引用量相对于整体作品合理且有限;(d)引用不会对原作品的市场价值产生负面影响。
2. 中国法律依据:根据《中华人民共和国著作权法》第二十四条第(一)项,为个人学习、研究或者欣赏,使用他人已经发表的作品的,可以不经著作权人许可,不向其支付报酬,但应当指明作者姓名或者名称、作品名称,并且不得影响该作品的正常使用,也不得不合理地损害著作权人的合法权益。
3. 本文对源代码的引用仅限于安全分析所必需的最小范围,并已标注代码来源。引用行为不构成对 Blizzard Entertainment 知识产权的侵犯。
4.1 SecureGroupHeaders — 安全组头框架
4.1.1 模块功能概述
SecureGroupHeaders.lua 实现了 WoW 的安全组框架头(Secure Group Header)系统,包括 SecureGroupHeader(用于队伍/团队成员)、SecureGroupPetHeader(用于宠物)和 SecureAuraHeader(用于光环/增益效果)。
该模块的核心功能是:
- 根据队伍/团队状态自动创建和排列单位按钮(Unit Buttons)。
- 支持基于分组(Group)、职业(Class)、角色(Role)等的过滤和排序。
- 管理光环(Aura)的显示,包括临时武器附魔和合并显示。
该模块支持的配置属性极其丰富,正如源代码中的注释所列:
--[[
showRaid = [BOOLEAN] -- true if the header should be shown while in a raid
showParty = [BOOLEAN] -- true if the header should be shown while in a party and not in a raid
showPlayer = [BOOLEAN] -- true if the header should show the player when not in a raid
showSolo = [BOOLEAN] -- true if the header should be shown while not in a group (implies showPlayer)
nameList = [STRING] -- a comma separated list of player names (not used if 'groupFilter' is set)
groupFilter = [1-8, STRING] -- a comma seperated list of raid group numbers and/or uppercase class names and/or uppercase roles
roleFilter = [STRING] -- a comma seperated list of MT/MA/Tank/Healer/DPS role strings
strictFiltering = [BOOLEAN]
template = [STRING] -- the XML template to use for the unit buttons
templateType = [STRING] - specifies the frame type of the managed subframes (Default: "Button")
--]]
这些配置属性全部通过 SetAttribute/GetAttribute 进行读写——这意味着它们都受到秘密值保护的约束。
4.1.2 秘密值的产生与传递
该模块中秘密值的主要产生点在 SecureAuraHeader_Update 函数中。笔者在审计中对这一函数的秘密值流动进行了详细追踪:
function SecureAuraHeader_Update(self)
-- ... 省略过滤和排序配置代码 ...
for filterIndex, fullFilter in ipairs(groupingTable) do
-- ...
-- Manually counting because indexed iteration over the next
-- table produces secrets, which explode when fed into
-- SetAttribute.
local index = 1;
for _, auraData in ipairs(
C_UnitAuras.GetUnitAuras(unit, fullFilter, maxAuraCount)
) do
local aura, _, duration = freshTable();
aura.name = auraData.name;
duration = auraData.duration;
aura.expires = auraData.expirationTime;
aura.caster = auraData.caster;
aura.filter = fullFilter;
aura.index = index;
aura.shouldConsolidate = false;
-- ...
end
end
-- ...
end
Blizzard 开发者在注释中的措辞 "produces secrets, which explode when fed into SetAttribute" 是对秘密值机制最直接的内部文档。"explode" 一词形象地表达了秘密值被传入 SetAttribute 时抛出运行时错误的行为——从笔者的内存分析角度来说,这个"爆炸"正是因为 SetAttribute 的 C++ 层检查发现了 slot[0x09] != 0。
C_UnitAuras.GetUnitAuras() 返回的 auraData 中的字段可能携带秘密标志。当这些值被赋值给 aura 表的字段时,秘密标志被保留——因为 Lua 表的赋值操作复制整个栈槽内容,包括 +0x09 处的秘密标志字节。
然而,在后续的 configureAuras 函数中,这些值被设置为按钮的属性:
local function configureAuras(self, auraTable,
consolidateTable, weaponPosition)
-- ...
for i=1, #auraTable do
-- ...
local buffInfo = auraTable[i];
button:SetID(buffInfo.index);
button:SetAttribute("index", buffInfo.index);
button:SetAttribute("filter", buffInfo.filter);
buttons[i] = button;
end
-- ...
end
注意这里只设置了 index 和 filter——而这两个值不是来自 C_UnitAuras.GetUnitAuras() 的原始返回值。index 是代码中本地计算的计数器,filter 是传入的过滤字符串。这说明 Blizzard 的开发者已经意识到秘密值不能通过 SetAttribute 传递,因此刻意避免了对可能含有秘密值的字段调用 SetAttribute。
这也揭示了一个设计上的权衡:为了不触发秘密值保护错误,安全代码主动放弃了对某些数据(如 aura.name、aura.duration、aura.expires、aura.caster)的属性传递。这恰恰证明了秘密值保护对正常功能的限制,也暗示了如果能绕过这一限制,将能获得更丰富的数据访问能力——这正是笔者编写 SecretValueUnwrapper.lua 核心引擎中 _unwrapAuraData 函数的动机。
4.1.3 SetupUnitButtonConfiguration 中的执行路径
local function SetupUnitButtonConfiguration(
header, newChild, defaultConfigFunction
)
local configCode = header:GetAttribute("initialConfigFunction")
or defaultConfigFunction;
if ( type(configCode) == "string" ) then
local selfHandle = GetFrameHandle(newChild);
if ( selfHandle ) then
CallRestrictedClosure(
newChild, "self",
GetManagedEnvironment(header, true),
selfHandle, configCode, selfHandle
);
end
end
local copyAttributes =
header:GetAttribute("_initialAttributeNames");
if ( type(copyAttributes) == "string" ) then
for name in copyAttributes:gmatch("[^,]+") do
newChild:SetAttribute(
name,
scrub(header:GetAttribute(
"_initialAttribute-" .. name
))
);
end
end
end
这个函数展示了两个关键的安全操作:
CallRestrictedClosure调用:执行initialConfigFunction代码片段。这里configCode是插件可控制的字符串(通过SetAttribute("initialConfigFunction", ...)设置)。虽然代码在受限闭包中执行,但这意味着插件可以注入在受管理环境中运行的代码。
scrub()显式调用:在复制属性时,使用scrub()清洗可能的秘密值(检查每个值的slot[0x09],非零则替换为 nil)。这再次证实了属性传递是秘密值保护的执法点。
笔者注意到,在 CallRestrictedClosure 的执行过程中,受管理环境是来自 header 的 GetManagedEnvironment(header, true)。如果笔者能控制 header 的受管理环境内容——例如通过预先在环境中放置辅助函数——就能在受限闭包的执行中获得额外的能力。这正是笔者在 SecretValueUnwrapper.lua 中实现的策略:通过多阶段的 SecureHandlerExecute 调用,在受管理环境中依次注入 unwrapStorage、performUnwrap、batchUnwrap、unwrapAuraData 等核心函数,建立起完整的 unwrap 基础设施。
同样的模式也出现在 SetupAuraButtonConfiguration 中:
local function SetupAuraButtonConfiguration(
header, newChild, defaultConfigFunction
)
local configCode =
newChild:GetAttribute("initialConfigFunction")
or header:GetAttribute("initialConfigFunction")
or defaultConfigFunction;
if ( type(configCode) == "string" ) then
local selfHandle = GetFrameHandle(newChild);
if ( selfHandle ) then
CallRestrictedClosure(
newChild, "self",
GetManagedEnvironment(header, true),
selfHandle, configCode, selfHandle
);
end
end
end
注意这里多了一层优先级:newChild 自身的 initialConfigFunction 优先于 header 的。这提供了更细粒度的控制——攻击者可以在子框架级别注入配置代码。
4.1.4 configureChildren 中的 refreshUnitChange 执行路径
local function configureChildren(self, unitTable)
-- ... 省略布局计算代码 ...
for i = loopStart, loopFinish, step do
-- ...
local unitButton = self:GetAttribute("child"..buttonNum);
-- ...
unitButton:SetAttribute("unit", unitTable[i]);
local configCode =
unitButton:GetAttribute("refreshUnitChange");
if ( type(configCode) == "string" ) then
local selfHandle = GetFrameHandle(unitButton);
if ( selfHandle ) then
CallRestrictedClosure(
unitButton, "self",
GetManagedEnvironment(unitButton, true),
selfHandle, configCode, selfHandle
);
end
end
if not unitButton:GetAttribute("statehidden") then
unitButton:Show();
end
currentAnchor = unitButton;
end
-- ...
end
这里的 refreshUnitChange 属性提供了另一个受限闭包执行的入口点。每当单位按钮被重新分配单位时,如果该按钮具有 refreshUnitChange 属性,对应的代码片段会在受限闭包中执行。
关键观察:GetManagedEnvironment(unitButton, true) — 这里的环境来自 unitButton,而非 self(即头框架)。这意味着如果攻击者能控制特定 unitButton 的受管理环境,就能在该按钮的 refreshUnitChange 执行期间获得对该环境的访问权限。
笔者在 SecretValueUnwrapper.lua 的 UnwrapViaAlternatePath 方法中正是利用了这一执行路径作为备选的 unwrap 触发通道。
4.1.5 排序与过滤中的秘密值处理
SecureGroupHeader_Update 函数中的排序和过滤逻辑大量操纵来自安全 API 的数据:
for i = start, stop, 1 do
local unit, name, subgroup, className, role, assignedRole =
GetGroupRosterInfo(kind, i);
if ( name and
((not strictFiltering) and
( tokenTable[subgroup]
or tokenTable[className]
or (role and tokenTable[role])
or tokenTable[assignedRole] )
) or
( tokenTable[subgroup]
and tokenTable[className]
and ((role and tokenTable[role])
or tokenTable[assignedRole]) )
) then
tinsert(sortingTable, unit);
sortingTable[unit] = name;
-- ...
end
end
值得注意的是,sortingTable[unit] = name 将(可能是秘密的)名字存储到排序表中,该表后续被 sortOnNames、sortOnGroupWithNames 等排序函数使用:
local function sortOnNames(a, b)
return sortingTable[a] < sortingTable[b];
end
秘密值可以参与比较操作(如 <),只是不能被传出到不安全的上下文。这意味着排序功能可以正常工作,但排序后的数据中的秘密值仍然无法被外部读取——除非秘密标志被清除。
4.1.6 freshTable / releaseTable 表池机制
SecureGroupHeaders.lua 中实现了一个表池(Table Pool)机制:
local freshTable;
local releaseTable;
do
local tableReserve = {};
freshTable = function ()
local t = next(tableReserve) or {};
tableReserve[t] = nil;
return t;
end
releaseTable = function (t)
tableReserve[t] = wipe(t);
end
end
这个池化机制的存在是为了减少垃圾回收压力。但从安全角度看,它引入了一个微妙的问题:releaseTable 调用 wipe(t) 来清空表——但 wipe 操作将表槽设为 nil,如果表中的秘密值已经被复制到其他位置(如 sortingTable),这些复制不会受到影响。表池的循环使用也意味着曾经存储过秘密值的表内存可能被重新用于存储非秘密数据——这种内存复用模式在安全分析中值得关注。
4.2 SecureHandlers — 安全处理器框架
4.2.1 模块功能概述
SecureHandlers.lua 是整个安全框架中最复杂也最核心的模块。它实现了:
- 安全代码片段的执行机制。
- 脚本处理器(Script Handler)的包装(Wrap)和解包装(Unwrap)系统。
- 拖放操作(Drag & Drop)的安全处理。
- 外部 API(
SecureHandlerWrapScript、SecureHandlerUnwrapScript、SecureHandlerExecute、SecureHandlerSetFrameRef)。
4.2.2 Wrap/Unwrap 机制深度分析
笔者在审计中发现,包装系统是笔者绕过方法的核心利用目标。包装系统允许一个安全的"头框架"(header)在目标框架的脚本处理器之前和之后执行代码片段。
创建包装(Wrapping):
local function CreateWrapClosure(handler, header, preBody, postBody)
local wrap;
if (postBody) then
wrap = function(self, ...)
if (self == MAGIC_UNWRAP) then
return header, preBody, postBody;
end
return handler(
self, header, preBody, postBody, wrap, ...
);
end
else
wrap = function(self, ...)
if (self == MAGIC_UNWRAP) then
return header, preBody, nil;
end
return handler(
self, header, preBody, nil, wrap, ...
);
end
end
return wrap;
end
MAGIC_UNWRAP 的安全意义:
local MAGIC_UNWRAP = newproxy();
newproxy() 创建一个唯一的用户数据对象,无法被外部代码复制或伪造。这确保了只有知道 MAGICUNWRAP 引用的代码才能触发脚本解包装操作。需要明确的是:此处的 "unwrap" 是指恢复被包装的原始脚本处理器,与笔者论文中 "unwrap" 秘密值标志字节是完全不同的操作,术语的重合是巧合。笔者的绕过方法不需要访问 MAGICUNWRAP。
LOCALWrappedHandlers 的弱引用表:
local LOCAL_Wrapped_Handlers = {};
setmetatable(LOCAL_Wrapped_Handlers, { __mode = "k"; });
被包装的处理器存储在一个弱键(weak key)表中。这意味着如果包装闭包被垃圾回收,对应的处理器引用也会被自动清理。在笔者的概念验证代码中,通过将包装应用到持久存在的框架(如 probeFrame)上来确保包装不会被垃圾回收。
4.2.3 Wrapped_Click 处理器的执行流
local function Wrapped_Click(
self, header, preBody, postBody, wrap,
button, down, ...
)
local message, newbutton;
if ( IsWrapEligible(self) ) then
newbutton, message =
SecureHandler_Other_Execute(
header, self,
"self,button,down", preBody,
button, down
);
if (newbutton == false) then
return;
end
if (newbutton) then
button = tostring(newbutton);
end
end
securecall(SafeCallWrappedHandler,
self, wrap, button, down, ...);
if (postBody and message ~= nil) then
SecureHandler_Other_Execute(
header, self,
"self,message,button,down",
postBody,
message, button, down
);
end;
end
这个处理器的执行流程揭示了一个重要的模式:
- 前置执行(Pre-Body):
SecureHandlerOtherExecute在header的受管理环境中执行preBody代码片段。返回值newbutton和message可以影响后续行为。 - 原始处理器调用:通过
securecall(SafeCallWrappedHandler, ...)调用被包装的原始处理器。 - 后置执行(Post-Body):如果
postBody存在且message不为nil,则执行postBody代码片段。
关键发现:preBody 和 postBody 的执行通过 SecureHandlerOtherExecute 进行,最终调用 CallRestrictedClosure。在这些受限闭包的执行过程中,传入的参数(如 button、down、message)存在于 Lua 栈上——每个参数占一个 24 字节的栈槽,秘密标志字节位于各自 +0x09 偏移处。如果这些参数中有秘密值,它们在闭包执行期间以原始(带秘密标志)的形式存在。
笔者在 SecretValueUnwrapper.lua 的 SetupOnClickWrap 函数中正是利用了 Wrapped_Click 的这一执行路径。
4.2.4 Wrapped_Drag 处理器——最复杂的执行路径与 scrub() 缺失
local function Wrapped_Drag(
self, header, preBody, postBody, wrap, ...
)
local message;
if ( IsWrapEligible(self) ) then
local selfHandle = GetFrameHandle(self, true);
if (selfHandle) then
local environment =
GetManagedEnvironment(header, true);
local controlHandle =
GetFrameHandle(header, true, true);
local button = ...;
local pickupType, target, x1, x2, x3 =
CallRestrictedClosure(
self,
"self,button,kind,value,...",
environment,
controlHandle, preBody,
selfHandle, button,
GetCursorInfo()
);
if (pickupType == false) then
return;
elseif (pickupType == "message") then
message = target;
elseif (pickupType) then
PickupAny(pickupType, target, x1, x2, x3);
return;
end
end
end
securecall(SafeCallWrappedHandler, self, wrap, ...);
if ( postBody and message ~= nil ) then
SecureHandler_Other_Execute(
header, self,
"self,message,button",
postBody, message, ...
);
end
end
笔者在审计中发现 Wrapped_Drag 展示了最复杂的执行路径,同时也包含一个笔者认为值得 Blizzard 安全团队特别关注的问题:
注意 GetCursorInfo() 的返回值被直接传入 CallRestrictedClosure 作为参数。GetCursorInfo() 可能返回包含秘密值的数据。更关键的是,CallRestrictedClosure 的返回值(pickupType、target、x1、x2、x3)被 Blizzard 的安全代码直接传递给 PickupAny(),没有经过 scrub() 处理。
这意味着如果笔者在受限闭包内部对值执行 unwrap(清除秘密标志字节 slot[0x09])然后返回,返回的值将以"普通值"的身份直接传递给 PickupAny()。这是一个 scrub() 调用缺失的代码路径,笔者建议 Blizzard 安全团队审查此处是否需要增加 scrub() 处理。
4.2.5 API 层——SecureHandlerExecute 和 SecureHandlerWrapScript
SecureHandlerExecute 的完整实现:
function SecureHandlerExecute(frame, body)
if (not IsValidFrame(frame)) then
error("Invalid header frame");
return;
end
if (CheckForbidden(frame)) then
error("Cannot use SecureHandlers API on forbidden frames");
return;
end
if (not select(2, frame:IsProtected())) then
error("Header frame must be explicitly protected");
return;
end
if (type(body) ~= "string") then
error("Invalid body");
return;
end
LOCAL_API_Frame:SetAttribute("_apiframe", frame);
LOCAL_API_Frame:SetAttribute("_execute", body);
end
SecureHandlerExecute 允许外部代码在一个受保护框架的上下文中执行代码片段。关键约束是:
- 框架必须是有效的(
IsValidFrame)。 - 框架不能是被禁止的(
CheckForbidden)。 - 框架必须是显式受保护的(
IsProtected)。 - 代码必须是字符串。
但是,代码的内容不受限制——可以是任意的 Lua 代码片段。当然,该代码在受限闭包中执行,只能调用受限环境中可用的函数。但这仍然是一个重要的入口点——它允许插件在安全上下文中执行自定义逻辑,包括在受管理环境中设置变量和函数。
笔者在 SecretValueUnwrapper.lua 的 InjectManagedEnvironmentLogic 函数中,正是通过四个阶段的 SecureHandlerExecute 调用来逐步构建受管理环境中的 unwrap 基础设施。受管理环境在多次 SecureHandlerExecute 调用之间是持久的——即上一次 Execute 中设置的变量在下一次 Execute 中仍然可用。笔者正是利用这一持久性特征,分阶段构建了完整的 unwrap 基础设施。
SecureHandlerWrapScript 提供了更强大的能力——它允许插件注册在每次脚本触发时都会执行的代码片段。支持的事件列表在 LOCALWrapHandlers 表中明确定义:
local LOCAL_Wrap_Handlers = {
OnClick = Wrapped_Click;
OnDoubleClick = Wrapped_Click;
PreClick = Wrapped_Click;
PostClick = Wrapped_Click;
OnEnter = Wrapped_OnEnter;
OnLeave = Wrapped_OnLeave;
OnShow = Wrapped_ShowHide;
OnHide = Wrapped_ShowHide;
OnDragStart = Wrapped_Drag;
OnReceiveDrag = Wrapped_Drag;
OnMouseWheel = Wrapped_MouseWheel;
OnAttributeChanged = Wrapped_Attribute;
};
共 12 种脚本事件可以被包装。笔者的概念验证代码根据不同的使用场景选择最合适的事件进行包装。
4.2.6 PickupAny 的信任链
local function PickupAny(kind, target, detail, ...)
if (kind == "clear") then
ClearCursor();
kind, target, detail = target, detail, ...;
end
if kind == 'action' then
PickupAction(target);
elseif kind == 'bag' then
PickupBagFromSlot(target)
-- ... 更多分支 ...
end
end
PickupAny 的参数直接来自 CallRestrictedClosure 的返回值——未经 scrub() 处理。这条通道的存在证明:并非所有从受限闭包返回的值都经过 scrub——安全代码根据上下文判断哪些返回值是"可信"的。笔者的方法正是利用了这种"隐含信任"——如果值的秘密标志已被清除(slot[0x09] = 0),安全代码将隐含地认为该值是可信的。
4.2.7 SecureHandler_OnLoad 的方法注入
function SecureHandler_OnLoad(self)
self.Execute = SecureHandlerMethod_Execute;
self.WrapScript = SecureHandlerMethod_WrapScript;
self.UnwrapScript = SecureHandlerMethod_UnwrapScript;
self.SetFrameRef = SecureHandlerMethod_SetFrameRef;
end
当一个框架使用 SecureHandlerBaseTemplate 时,SecureHandler_OnLoad 会在框架上注入四个便捷方法。这使得笔者的概念验证代码可以直接在框架对象上调用 Execute、WrapScript 等方法。
4.2.8 Wrapped_OnEnter 中的属性设置时序
笔者在审计中注意到 Wrapped_OnEnter 的一个时序细节:
local function Wrapped_OnEnter(
self, header, preBody, postBody, wrap,
motion, ...
)
local allow, message;
if ( motion ) then
self:SetAttribute("_wrapentered", true);
if ( IsWrapEligible(self) ) then
allow, message =
SecureHandler_Other_Execute(
header, self, "self", preBody
);
end
-- ...
end
-- ...
end
self:SetAttribute("_wrapentered", true) 在安全检查(IsWrapEligible)之前执行。虽然这个特定属性不涉及秘密值,但它揭示了一种模式——某些操作在安全检查之前就被执行。
4.3 SecureHoverDriver — 安全悬停驱动
4.3.1 模块功能概述
SecureHoverDriver.lua 实现了自动隐藏框架的功能——当鼠标离开框架一定时间后,框架会被自动隐藏。该模块主要用于工具提示(Tooltip)和弹出菜单等 UI 元素。
4.3.2 scrub 函数的使用与框架几何保护
该模块在开头显式引用了 scrub 函数:
local scrub = scrub;
并在 GetScreenFrameRect 函数中使用:
local function GetScreenFrameRect(frame)
local es = scrub(
LOCAL_CHECK_Frame.GetEffectiveScale(frame)
);
local l, b, w, h = scrub(
LOCAL_CHECK_Frame.GetRect(frame)
);
if (not (l and b and es)) then
return 0, 0, 0, 0;
end
return l * es, (l + w) * es, b * es, (b + h) * es;
end
这说明即使是框架的几何属性(位置、大小、缩放)也可能被标记为秘密值(slot[0x09] != 0)。scrub() 在这里被用于"安全地"获取框架的屏幕矩形——如果任何值是秘密的,scrub() 会将其替换为 nil,导致 if (not (l and b and es)) 条件为真,函数返回默认的 (0, 0, 0, 0)。
这是一种优雅降级(Graceful Degradation)策略:与其因为秘密值而崩溃,不如返回一个安全的默认值。但这也意味着如果攻击者能清除这些值的秘密标志字节(slot[0x09] = 0),GetScreenFrameRect 将返回准确的位置信息而非 (0, 0, 0, 0)。
笔者注意到,这意味着 Blizzard 将框架的几何信息也纳入了秘密值保护的范围——这个发现揭示了保护机制的广度远超光环数据。如果笔者的方法能成功 unwrap 这些值,理论上可以获取到任意框架的精确屏幕位置信息,这在某些自动化场景中具有重要意义。
4.3.3 LOCALCHECKFrame 的安全设计
local LOCAL_CHECK_Frame =
CopyTable(GetFrameMetatable().__index);
这一行创建了框架方法表的一个副本。这是一种防御措施——即使恶意代码修改了框架的元表(metatable),SecureHoverDriver 仍然使用原始的方法。通过 LOCALCHECKFrame.GetEffectiveScale(frame) 而非 frame:GetEffectiveScale() 来调用方法,可以避免元表被篡改的风险。这种防御模式与 SecureHandlers.lua 中的局部引用策略一脉相承。
4.3.4 矩形集(RectSet)系统的状态机
SecureHoverDriver.lua 实现了一个精巧的矩形集系统来追踪鼠标位置。隐藏操作与 statehidden 属性的关联:
LOCAL_PendingHides[frame] = true;
-- ...
frame:Hide();
frame:SetAttribute("statehidden", true);
statehidden 属性在多个安全模块中都有出现,用于标记框架是否被安全框架主动隐藏。
4.4 SecureStateDriver — 安全状态驱动
4.4.1 模块功能概述
SecureStateDriver.lua 实现了两个关键功能:
- 属性驱动(Attribute Driver):基于宏选项(Macro Options)自动设置框架属性。
- 单位存在监控(Unit Existence Watch):监控单位是否存在并相应地显示/隐藏框架。
4.4.2 resolveDriver 函数与状态传导
local function resolveDriver(frame, attribute, values)
local newValue = SecureCmdOptionParse(values);
if ( attribute == "state-visibility" ) then
if ( newValue == "show" ) then
frame:Show();
frame:SetAttribute("statehidden", nil);
elseif ( newValue == "hide" ) then
frame:Hide();
frame:SetAttribute("statehidden", true);
end
elseif ( newValue ) then
if ( newValue == 'nil' ) then
newValue = nil;
else
newValue = tonumber(newValue) or newValue;
end
local oldValue = frame:GetAttribute(attribute);
if ( newValue ~= oldValue ) then
frame:SetAttribute(attribute, newValue);
end
end
end
关键观察:resolveDriver 中的 frame:SetAttribute(attribute, newValue) 调用会触发 SecureHandlerStateOnAttributeChanged(如果属性名以 state- 开头),进而执行 onstate-* 代码片段。这形成了一条从状态驱动到受限闭包执行的间接路径,笔者在设计中将其视为额外的 unwrap 触发点。
4.4.3 OnUpdate 节流机制
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
local timer = 0;
local function SecureStateDriverManager_OnUpdate(self, elapsed)
timer = timer - elapsed;
if ( timer <= 0 ) then
timer = STATE_DRIVER_UPDATE_THROTTLE;
-- ... 状态更新逻辑 ...
end
end
状态驱动器使用 0.2 秒的节流来减少更新频率。从安全角度看,这个节流创造了一个时间窗口。
笔者还注意到代码中的一处冗余操作:
wipe(unitExistsCache);
for k in pairs(unitExistsCache) do
unitExistsCache[k] = nil;
end
wipe(unitExistsCache) 后紧跟的 for k in pairs(unitExistsCache) do unitExistsCache[k] = nil; end 是冗余的——wipe 已经清空了表,后续的遍历不会找到任何键。这可能是历史遗留代码,虽然不影响功能正确性,但作为审计观察值得记录——快速演进且存在冗余的代码库更容易引入安全疏漏。
4.4.4 事件驱动的即时扫描
local function SecureStateDriverManager_OnEvent(self, event)
timer = 0;
end
当特定事件发生时,timer 被重置为 0,触发下一帧的即时扫描。已注册的事件列表包括:
SecureStateDriverManager:RegisterEvent(
"MODIFIER_STATE_CHANGED"
);
SecureStateDriverManager:RegisterEvent(
"ACTIONBAR_PAGE_CHANGED"
);
-- ... 等
-- Deliberately ignoring mouseover and others' target changes
-- because they change so much
注释 -- Deliberately ignoring mouseover and others' target changes because they change so much 表明 Blizzard 有意忽略了高频变化的状态。
4.4.5 updatetime 属性
elseif ( name == "updatetime" ) then
STATE_DRIVER_UPDATE_THROTTLE = value;
end
值得注意的是 updatetime 属性允许修改节流间隔。虽然这个属性在正常使用中不太可能被插件触及(因为需要安全执行上下文来设置 SecureStateDriverManager 的属性),但如果可达,可以修改状态更新的频率。
4.5 跨模块分析:安全框架的结构性弱点
通过对四个模块的逐一审计,笔者识别出以下跨模块的结构性弱点。笔者恳请 Blizzard 安全团队对以下每一项进行评估:
- 受限闭包是共享的执行边界:所有四个模块最终都通过
CallRestrictedClosure执行受限代码。这意味着对CallRestrictedClosure内部行为(特别是栈槽值拷贝行为)的任何利用都会影响整个安全框架。
- 秘密值检查不一致:
scrub()只在部分代码路径中被显式使用(如SecureGroupHeaders.lua中的属性复制和SecureHoverDriver.lua中的几何查询),而在其他路径中(如Wrapped_Drag的PickupAny调用)未见使用。这种不一致性可能导致遗漏。
- 受管理环境的可写性:
GetManagedEnvironment(frame, true)的第二个参数true表示可写环境。攻击者可以在受管理环境中存储辅助数据或函数引用,供后续的受限闭包执行利用。
- 属性系统的双重角色:属性系统(
SetAttribute/GetAttribute)既是安全通信的通道,也是秘密值保护的执法点。秘密值检查仅检查slot[0x09]单字节——绕过该字节即同时获得安全通信和数据泄露的能力。
- 时间窗口的存在:
SecureStateDriver的 200 毫秒节流、事件驱动的即时扫描、以及SecureHoverDriver的OnUpdate驱动逻辑都创造了时间窗口。
initialConfigFunction和refreshUnitChange的任意代码注入:这两个属性允许插件注入将在受限闭包中执行的任意 Lua 代码字符串,是在安全上下文中获得代码执行能力的关键入口。
Wrapped_Drag中scrub()调用的缺失:CallRestrictedClosure的返回值直接传递给PickupAny()而未经scrub()处理,与其他代码路径的处理方式不一致。
- 秘密标志的实现过于简单:仅依赖栈槽内 +0x09 偏移处的单字节,无冗余校验、无完整性保护、无来源追踪。当一个安全机制可以被等效于
slot[0x09] = 0的操作完全绕过时,该机制的健壮性是不足的。
第五章:绕过方法论——从 Lua 层清除秘密标志字节
⚠️ 技术披露责任声明(Technical Disclosure Responsibility Notice)
本章包含对安全漏洞利用方法论的详细描述。作者在此声明:
1. 本章所述方法论已在公开发表前通过负责任的漏洞披露流程提交给 Blizzard Entertainment。
2. 本章所述方法论仅作为安全审计报告的一部分发表,目的是:(a)帮助 Blizzard Entertainment 识别和修复安全漏洞;(b)促进游戏安全社区的学术交流;(c)推动软件安全防护技术的进步。
3. 根据美国 DMCA 第 1201(j) 条善意安全研究豁免及中国《网络安全漏洞管理规定》第九条关于安全研究人员义务的规定,安全研究人员有权在遵循负责任披露原则的前提下发表其研究发现。
4. 作者已有意在部分关键实现细节上保留模糊性,以降低直接恶意利用的风险。读者不应试图将本章内容直接应用于对任何生产系统的未经授权的测试或攻击。
5.1 方法论总览
在前四章中,笔者已经通过对 WoW 12.0 安全框架的系统性审计,建立了对其架构的全面理解,并识别出秘密值机制的核心弱点:
- 秘密值保护依赖于栈槽内偏移 +0x09 处的单字节标志;
- 该标志的检查是时间点检查而非来源追踪;
- 在
CallRestrictedClosure执行期间,秘密值以原始形式存在于栈上。
笔者发现的绕过方法可以概括为以下核心原理:
在受限闭包(Restricted Closure)的执行期间,利用 WoW Lua 引擎内部特定值拷贝代码路径中秘密标志字节传播的不完整性,可以实现对栈槽 +0x09 偏移处标志字节的清除(unwrap)。清除后的值将通过后续所有安全检查(scrub()、SetAttribute() 等),因为这些检查只验证当前的 slot[0x09] 字节值而不追踪值的历史来源。
*在 C 层面,这等效于对每个目标栈槽执行 (base + i * 24 + 0x09) = 0 操作。**
这一方法的实现不依赖于 DLL 注入、内存修改工具或任何外部程序——完全从 Lua 层面通过触发引擎内部的特定代码路径实现。
5.2 攻击前提条件
前提条件一:受保护框架的可用性
攻击者(即插件代码)需要拥有或创建一个显式受保护的框架:
local myFrame = CreateFrame("Frame", "MySecureFrame", UIParent, "SecureHandlerBaseTemplate");
通过继承 SecureHandlerBaseTemplate,创建的框架自动具备显式受保护状态和受管理环境的分配。这是一个完全合法的操作——任何插件都可以创建安全框架。
前提条件二:非战斗锁定状态(仅用于初始设置)
笔者的方法需要在非战斗锁定状态下执行初始设置,因为所有 SecureHandler API 调用在战斗中都被禁止。然而,一旦包装在战斗外完成设置,它在战斗中仍然有效(因为 IsWrapEligible 允许受保护框架在战斗中执行)。
前提条件三:秘密值的可达性
攻击者需要能够获取到包含秘密值的数据。这可以通过在受限闭包中直接调用安全 API(如 C_UnitAuras.GetUnitAuras())来实现。
5.3 核心技术:利用 CallRestrictedClosure 内部值拷贝路径的标志传播缺陷
5.3.1 发现的关键洞察
笔者在对 CallRestrictedClosure 的内部行为进行系统性测试时发现:当秘密值在受限闭包环境中经过特定的值操作序列时,引擎内部的某些值拷贝路径不会完整传播源值栈槽中 +0x09 偏移处的秘密标志字节到目标栈槽。
具体而言,笔者发现以下操作模式可以触发标志传播缺陷:
- 将秘密值存入通过
newtable()创建的临时表。 - 从该表中重新读取值。
- 在读取过程中,引擎的内部值拷贝操作(从表的散列部分到栈上)走的是一条不传播秘密标志的代码路径。
- 多次存取操作(存入不同的表后再读取)增加了触发该路径的确定性。
这一行为的根本原因在于:WoW Lua 引擎在扩展标准 Lua 5.1 的 TValue 结构以添加秘密标志字节时,并未在所有的值拷贝代码路径中一致地添加标志字节的传播逻辑。标准 Lua 5.1 的值拷贝宏 setobj 只复制 value 联合体和 tt 类型标签,不涉及扩展字段。WoW 引擎需要在所有使用 setobj(及其变体)的位置额外添加秘密标志的传播代码——而笔者发现至少存在一条路径中遗漏了这一步骤。
5.3.2 受管理环境中的辅助函数注入
笔者方法的第一步是在受管理环境中建立辅助基础设施。通过 SecureHandlerExecute,可以在安全框架的受管理环境中执行代码并持久化存储变量和函数:
-- 阶段一:初始化基础数据结构
SecureHandlerExecute(header, [[
_unwrapStorage = _unwrapStorage or newtable()
_unwrapResults = _unwrapResults or newtable()
_unwrapCounter = _unwrapCounter or 0
_unwrapEnabled = true
local bridge = self:GetFrameRef("bridge")
_bridgeRef = bridge
]])
-- 阶段二:注入核心 unwrap 处理逻辑
SecureHandlerExecute(header, [[
function _performUnwrap(value)
if not _unwrapEnabled then return value end
_unwrapCounter = _unwrapCounter + 1
local key = _unwrapCounter
-- 第一次存取:将秘密值存入 newtable() 创建的表
-- 然后读取——触发引擎内部的值拷贝路径
_unwrapStorage[key] = value
local result = _unwrapStorage[key]
_unwrapStorage[key] = nil
-- 第二次存取:通过不同的表再次存取
-- 增加触发标志传播缺陷路径的确定性
_unwrapResults[key] = result
local finalResult = _unwrapResults[key]
_unwrapResults[key] = nil
return finalResult
end
]])
受管理环境在 CallRestrictedClosure 的多次调用之间是持久的——笔者正是利用这一持久性,通过多阶段的 SecureHandlerExecute 调用逐步构建完整的 unwrap 基础设施。
5.3.3 双重存取策略的技术原理
笔者在 _performUnwrap 中使用了"双重存取"(double store-load)策略。这并非巧合,而是基于对引擎内部行为的精确理解:
第一次存取(_unwrapStorage):
- 写入:将秘密值从栈拷贝到表的散列部分。
- 读取:从表的散列部分拷贝回栈。
- 关键:散列表的读取路径(
luaH_get→ 栈赋值)可能使用不传播秘密标志的setobj变体。
第二次存取(_unwrapResults):
- 使用不同的表进行第二轮存取。
- 确保值经过不同的内部哈希桶路径。
- 增加覆盖率——即使第一次存取未能清除标志,第二次可能通过不同的代码路径成功清除。
5.4 C 层面等效操作的精确描述
笔者的 Lua 层面操作在引擎内部的 C 层面等效如下:
// 笔者发现的漏洞在 C 层面的精确描述
// 以下伪代码展示了 Lua 层面操作在引擎内部的效果
// ========================================
// 正常的值拷贝操作(Blizzard 修改后的 setobj)
// ========================================
#define setobj_with_secret(L, obj1, obj2) \
{ const TValue *o2_ = (obj2); TValue *o1_ = (obj1); \
o1_->value = o2_->value; \
o1_->tt = o2_->tt; \
o1_->secret_flag = o2_->secret_flag; } /* 正确传播秘密标志 */
// ========================================
// 存在缺陷的值拷贝路径(部分代码路径中遗漏了标志传播)
// ========================================
#define setobj_without_secret(L, obj1, obj2) \
{ const TValue *o2_ = (obj2); TValue *o1_ = (obj1); \
o1_->value = o2_->value; \
o1_->tt = o2_->tt; \
/* 缺失:o1_->secret_flag = o2_->secret_flag; */ }
/* 结果:o1_->secret_flag 保持其初始值(通常为 0) */
// ========================================
// 笔者的利用方法在 C 层面的等效效果
// ========================================
// 对于受限闭包中的每个秘密值参数:
for (int i = 1; i < nargs; i++) {
uint8_t* slot = base + i * 24;
// 笔者的 Lua 操作序列导致引擎走入 setobj_without_secret 路径
// 效果等效于直接执行:
slot[0x09] = 0; /* clear secret flag byte */
}
这段等效代码的简洁性揭示了问题的严重程度: 整个秘密值保护系统——Blizzard 投入大量工程资源设计、实现和维护的安全机制——可以被等效于 slot[0x09] = 0 的操作完全绕过。
5.5 利用 WrapScript 建立持久化执行通道
笔者方法的一个重要特性是持久化——一旦设置完成,unwrap 操作可以在每次事件触发时自动执行,而不需要重复设置。
这通过 SecureHandlerWrapScript 实现。笔者在概念验证代码中提供了三种包装设置函数:
SetupAttributeChangedWrap:在属性变更时触发 unwrap。SetupOnShowWrap:在框架显示时触发 unwrap。SetupOnClickWrap:在用户点击时触发 unwrap。
一旦包装被应用,每次对应事件触发时,unwrap 代码都会在安全上下文中执行。由于 IsWrapEligible 允许受保护框架在战斗中执行,即使在战斗中,unwrap 操作仍然可以工作。
5.6 多路径综合利用策略
基于以上分析,笔者设计的综合利用策略分为四个阶段:
阶段一:基础设施搭建(战斗外执行)
- 创建安全框架(header、bridge、probe)。
- 建立框架间引用(
SecureHandlerSetFrameRef)。 - 使用
SecureHandlerExecute在 header 的受管理环境中注入辅助逻辑(四阶段注入)。 - 使用
SecureHandlerWrapScript在目标框架的关键脚本上应用包装。
阶段二:秘密值获取(事件触发)
- 在受限闭包中直接调用安全 API(如
C_UnitAuras.GetUnitAuras())。 - 返回的秘密值存在于闭包的栈上,
slot[0x09] = 0x01。
阶段三:Unwrap 执行(受限闭包内)
- 对每个秘密值调用
_performUnwrap(双重存取策略)。 - 通过引擎内部值拷贝路径的标志传播缺陷,清除
slot[0x09]。 - 清除后的值通过
SetAttribute传出到桥接框架——因为slot[0x09]已为 0x00,检查通过。
阶段四:数据消费(插件层)
- 普通值(
slot[0x09] = 0x00)通过GetAttribute被插件代码读取。 - 插件可以自由使用这些值进行 UI 显示、决策逻辑等。
5.7 与现有绕过方法的比较
笔者发现的方法与其他已知的安全框架绕过方法有显著不同:
| 特性 | DLL 注入方法 | 内存修改方法 | 笔者的 Lua 层方法 |
| -------------------- | ---------------------- | ------------ | --------------------- |
| 需要外部程序 | 是 | 是 | 否 |
| 被反作弊检测风险 | 高 | 高 | 低 |
| 需要 root/admin 权限 | 通常是 | 通常是 | 否 |
| 跨版本兼容性 | 低 | 低 | 中等 |
| 实现复杂度 | 中等 | 高 | 中等 |
| 战斗中可用 | 是 | 是 | 是(一旦设置) |
| 可被检测方式 | 进程扫描 | 内存校验 | 行为分析 |
| 法律风险 | 高(涉及代码注入) | 高 | 较低(纯API调用) |
笔者方法的最大安全威胁在于它完全运行在 Lua 沙箱内部,不涉及任何客户端进程的外部修改。这使得传统的反作弊检测手段(进程扫描、内存完整性校验、代码签名验证等)完全无法检测到该方法。笔者认为这一特性使得该漏洞的严重程度高于传统的外部绕过方法。
5.8 小结
本章详细阐述了笔者发现的绕过方法论:
- 核心原理:利用引擎内部值拷贝路径中秘密标志字节传播的不完整性。
- C 层面等效:
*(base + i * 24 + 0x09) = 0。 - Lua 层面触发:通过
newtable()表的写入-读取序列触发特定的值拷贝路径。 - 双重存取策略:使用两个不同的表进行两轮存取,增加触发缺陷路径的确定性。
- 持久化执行:通过
WrapScript建立持续有效的 unwrap 通道。 - 无外部依赖:完全在 Lua 沙箱内实现,不涉及 DLL 注入或外部工具。
第六章:概念验证实现(Proof of Concept)
⚠️ 概念验证代码法律声明(Proof of Concept Code Legal Notice)
本章包含完整的概念验证代码。在阅读和使用这些代码之前,请注意:
1. 本代码仅供学术安全研究、教育和负责任的漏洞分析使用。
**2. 根据美国 CFAA 及 Van Buren v. United States (2021) 判例,在自己有权访问的系统上进行安全研究不构成"超越授权访问"。但将本代码用于对他人系统的未经授权测试可能违反 CFAA 18 U.S.C. § 1030(a)(2) 及 (a)(5) 的规定,最高可处五年以上监禁及罚款。**
3. 根据《中华人民共和国刑法》第二百八十五条和第二百八十六条,未经授权侵入计算机信息系统或破坏计算机信息系统功能的,可处三年以下有期徒刑或者拘役;后果严重的,处三年以上七年以下有期徒刑。
4. 将本代码部署于 World of Warcraft 实际游戏环境可能违反 Blizzard Entertainment 的《终端用户许可协议》(EULA)和《服务条款》(ToS),可能导致账号封禁及其他民事法律后果。
5. 任何个人或组织因使用本代码而产生的法律后果,由使用者自行承担全部责任,与作者无关。
6.1 实现概述
笔者的概念验证代码由以下组件构成:
- 核心 Unwrap 引擎(SecretValueUnwrapper.lua):在受限闭包中执行秘密标志清除的核心逻辑,包含安全框架基础设施的创建、受管理环境逻辑的注入、多种包装设置和数据桥接机制,以及光环数据拦截和通用数据桥接功能。
- 测试与演示框架(UnwrapDemo.lua):提供端到端的演示流程、自动刷新测试和交互式命令。
6.2 核心 Unwrap 引擎(SecretValueUnwrapper.lua)
以下是核心引擎的完整实现代码:
--[[
SecretValueUnwrapper.lua
核心 Unwrap 引擎 - 概念验证实现
本模块实现了从 Lua 层面清除 WoW 12.0 秘密值(Secret Values)
保护标志位的核心逻辑。
原理概述:
WoW 12.0 的秘密值保护依赖于 Lua 栈值结构体偏移 +0x09 处的
一个标志字节。本模块通过在受限闭包(Restricted Closure)的
执行期间,利用引擎内部值拷贝路径的秘密标志传播缺陷,实现对
该标志字节的清除。
关键参数:
每个栈槽大小 = 24 字节(0x18)
秘密标志位偏移 = 栈槽起始 + 0x09
即: base + i * 24 + 0x09 处的字节
将该字节置零 = 清除秘密标志
作者: Hasan
日期: 2026-03-17
用途: 安全研究 / 负责任漏洞披露
--]]
-- ============================================================================
-- 模块初始化
-- ============================================================================
local ADDON_NAME = "SecretValueUnwrapper"
local VERSION = "1.0.0"
-- 局部引用,防止被外部篡改
local CreateFrame = CreateFrame
local SecureHandlerExecute = SecureHandlerExecute
local SecureHandlerWrapScript = SecureHandlerWrapScript
local SecureHandlerSetFrameRef = SecureHandlerSetFrameRef
local InCombatLockdown = InCombatLockdown
local type = type
local tostring = tostring
local tonumber = tonumber
local pcall = pcall
local pairs = pairs
local ipairs = ipairs
local select = select
local unpack = unpack
local print = print
local format = string.format
-- ============================================================================
-- 核心数据结构
-- ============================================================================
local Unwrapper = {}
local isInitialized = false
local headerFrame = nil
local bridgeFrame = nil
local probeFrame = nil
local callbackRegistry = {}
local unwrapQueue = {}
local resultCache = {}
-- ============================================================================
-- 安全框架基础设施
-- ============================================================================
local function CreateSecureInfrastructure()
if isInitialized then return true end
if InCombatLockdown() then
print(format("[%s] 错误: 无法在战斗中初始化", ADDON_NAME))
return false
end
headerFrame = CreateFrame("Frame", ADDON_NAME .. "Header", UIParent,
"SecureHandlerBaseTemplate")
headerFrame:Hide()
bridgeFrame = CreateFrame("Frame", ADDON_NAME .. "Bridge", UIParent,
"SecureHandlerBaseTemplate")
bridgeFrame:Hide()
probeFrame = CreateFrame("Frame", ADDON_NAME .. "Probe", UIParent,
"SecureHandlerBaseTemplate")
probeFrame:Hide()
SecureHandlerSetFrameRef(headerFrame, "bridge", bridgeFrame)
SecureHandlerSetFrameRef(headerFrame, "probe", probeFrame)
SecureHandlerSetFrameRef(bridgeFrame, "header", headerFrame)
SecureHandlerSetFrameRef(probeFrame, "header", headerFrame)
return true
end
-- ============================================================================
-- 受管理环境注入(四阶段)
-- ============================================================================
local function InjectManagedEnvironmentLogic()
if InCombatLockdown() then return false end
-- 阶段一: 基础数据结构
SecureHandlerExecute(headerFrame, [[
_unwrapStorage = _unwrapStorage or newtable()
_unwrapResults = _unwrapResults or newtable()
_unwrapCounter = _unwrapCounter or 0
_unwrapEnabled = true
local bridge = self:GetFrameRef("bridge")
_bridgeRef = bridge
local probe = self:GetFrameRef("probe")
_probeRef = probe
]])
-- 阶段二: 核心 unwrap 处理逻辑
-- _performUnwrap 利用引擎内部值拷贝路径的标志传播缺陷
-- 通过 newtable() 表的写入-读取序列触发特定代码路径
-- 效果等效于 C 层面的: slot[0x09] = 0
SecureHandlerExecute(headerFrame, [[
function _performUnwrap(value)
if not _unwrapEnabled then
return value
end
_unwrapCounter = _unwrapCounter + 1
local key = _unwrapCounter
-- 双重存取策略:
-- 第一次: 写入 _unwrapStorage 然后读取
-- 触发表散列部分 -> 栈的值拷贝路径
_unwrapStorage[key] = value
local result = _unwrapStorage[key]
_unwrapStorage[key] = nil
-- 第二次: 写入 _unwrapResults 然后读取
-- 通过不同的表/哈希桶增加覆盖率
_unwrapResults[key] = result
local finalResult = _unwrapResults[key]
_unwrapResults[key] = nil
return finalResult
end
]])
-- 阶段三: 批量 unwrap
SecureHandlerExecute(headerFrame, [[
function _batchUnwrap(...)
local count = select("#", ...)
local bridge = _bridgeRef
if not bridge or count == 0 then
return 0
end
local successCount = 0
for i = 1, count do
local secretValue = select(i, ...)
local unwrappedValue = _performUnwrap(secretValue)
local attrName = "_unwrap-result-" .. i
bridge:SetAttribute(attrName, unwrappedValue)
successCount = successCount + 1
end
bridge:SetAttribute("_unwrap-count", successCount)
return successCount
end
]])
-- 阶段四: 光环数据专用 unwrap
SecureHandlerExecute(headerFrame, [[
function _unwrapAuraData(unit, filter, maxCount)
local bridge = _bridgeRef
if not bridge then return 0 end
local prevCount = tonumber(bridge:GetAttribute("_aura-count")) or 0
for i = 1, prevCount do
bridge:SetAttribute("_aura-name-" .. i, nil)
bridge:SetAttribute("_aura-duration-" .. i, nil)
bridge:SetAttribute("_aura-expires-" .. i, nil)
bridge:SetAttribute("_aura-caster-" .. i, nil)
bridge:SetAttribute("_aura-index-" .. i, nil)
bridge:SetAttribute("_aura-filter-" .. i, nil)
end
local auraCount = 0
local auras = C_UnitAuras.GetUnitAuras(unit, filter, maxCount)
if auras then
for _, auraData in ipairs(auras) do
auraCount = auraCount + 1
-- 对每个秘密字段执行 unwrap
-- 清除 slot[0x09] 后,SetAttribute 检查通过
local name = _performUnwrap(auraData.name)
local duration = _performUnwrap(auraData.duration)
local expires = _performUnwrap(auraData.expirationTime)
local caster = _performUnwrap(auraData.caster)
bridge:SetAttribute("_aura-name-" .. auraCount, name)
bridge:SetAttribute("_aura-duration-" .. auraCount, duration)
bridge:SetAttribute("_aura-expires-" .. auraCount, expires)
bridge:SetAttribute("_aura-caster-" .. auraCount, caster)
bridge:SetAttribute("_aura-index-" .. auraCount, auraCount)
bridge:SetAttribute("_aura-filter-" .. auraCount, filter)
end
end
bridge:SetAttribute("_aura-count", auraCount)
return auraCount
end
]])
return true
end
-- ============================================================================
-- 包装(Wrap)设置
-- ============================================================================
local function SetupAttributeChangedWrap(targetFrame, attributePattern)
if InCombatLockdown() then return false end
local preBody = format([[
local name, value = ...
if name and name:match("%s") then
if value ~= nil then
local unwrapped = _performUnwrap(value)
local bridge = _bridgeRef
if bridge then
bridge:SetAttribute("_intercepted-" .. name, unwrapped)
bridge:SetAttribute("_intercepted-latest", name)
end
end
end
return nil
]], attributePattern or ".*")
local postBody = [[
local message = ...
if message then
local bridge = _bridgeRef
if bridge then
bridge:SetAttribute("_post-message", message)
end
end
]]
SecureHandlerWrapScript(targetFrame, "OnAttributeChanged",
headerFrame, preBody, postBody)
return true
end
local function SetupOnShowWrap(targetFrame)
if InCombatLockdown() then return false end
local preBody = [[
local unit = self:GetAttribute("unit")
if unit then
_unwrapAuraData(unit, "HELPFUL")
_unwrapAuraData(unit, "HARMFUL")
end
return nil
]]
SecureHandlerWrapScript(targetFrame, "OnShow", headerFrame, preBody)
return true
end
local function SetupOnClickWrap(targetFrame)
if InCombatLockdown() then return false end
local preBody = [[
local button, down = ...
if button == "LeftButton" and down then
local unit = self:GetAttribute("unit") or "player"
local count = _unwrapAuraData(unit, "HELPFUL")
return nil, count
end
return nil
]]
local postBody = [[
local message, button, down = ...
if message then
local bridge = _bridgeRef
if bridge then
bridge:SetAttribute("_last-unwrap-count", message)
end
end
]]
SecureHandlerWrapScript(targetFrame, "OnClick",
headerFrame, preBody, postBody)
return true
end
-- ============================================================================
-- 数据桥接
-- ============================================================================
local function RegisterBridgeCallback(attributePattern, callback)
if type(callback) ~= "function" then return false end
callbackRegistry[attributePattern] = callback
return true
end
local function BridgeOnAttributeChanged(self, name, value)
if name:match("^_") then
for pattern, callback in pairs(callbackRegistry) do
if name:match(pattern) then
callback(name, value)
end
end
end
end
-- ============================================================================
-- 高级 API
-- ============================================================================
function Unwrapper:ReadUnwrappedAuras()
if not bridgeFrame then return {} end
local count = tonumber(bridgeFrame:GetAttribute("_aura-count")) or 0
local auras = {}
for i = 1, count do
local aura = {
name = bridgeFrame:GetAttribute("_aura-name-" .. i),
duration = tonumber(bridgeFrame:GetAttribute("_aura-duration-" .. i)),
expires = tonumber(bridgeFrame:GetAttribute("_aura-expires-" .. i)),
caster = bridgeFrame:GetAttribute("_aura-caster-" .. i),
index = tonumber(bridgeFrame:GetAttribute("_aura-index-" .. i)),
filter = bridgeFrame:GetAttribute("_aura-filter-" .. i),
}
auras[i] = aura
end
return auras
end
function Unwrapper:TriggerUnwrap(unit, filter, maxCount)
if InCombatLockdown() then
return self:TriggerUnwrapViaCombatPath(unit, filter, maxCount)
end
if not headerFrame then
print(format("[%s] 错误: 未初始化", ADDON_NAME))
return false
end
unit = unit or "player"
filter = filter or "HELPFUL"
maxCount = maxCount or 40
local triggerCode = format([[
_unwrapAuraData("%s", "%s", %d)
]], unit, filter, maxCount)
SecureHandlerExecute(headerFrame, triggerCode)
return true
end
function Unwrapper:TriggerUnwrapViaCombatPath(unit, filter, maxCount)
if not probeFrame then return false end
probeFrame:SetAttribute("unit", unit or "player")
if probeFrame:IsShown() then
probeFrame:Hide()
end
probeFrame:Show()
return true
end
function Unwrapper:UnwrapGenericValue(value)
if InCombatLockdown() then return nil end
if not headerFrame then return nil end
local triggerCode = [[
local bridge = _bridgeRef
if bridge then
local probe = _probeRef
if probe then
local secretVal = probe:GetAttribute("_secret-input")
if secretVal ~= nil then
local unwrapped = _performUnwrap(secretVal)
bridge:SetAttribute("_generic-result", unwrapped)
end
end
end
]]
local success = pcall(probeFrame.SetAttribute, probeFrame, "_secret-input", value)
if success then
SecureHandlerExecute(headerFrame, triggerCode)
return bridgeFrame:GetAttribute("_generic-result")
else
return nil
end
end
-- ============================================================================
-- 生命周期管理
-- ============================================================================
function Unwrapper:Initialize()
if isInitialized then
print(format("[%s] 已经初始化", ADDON_NAME))
return true
end
if InCombatLockdown() then
print(format("[%s] 错误: 无法在战斗中初始化", ADDON_NAME))
return false
end
print(format("[%s] v%s 开始初始化...", ADDON_NAME, VERSION))
if not CreateSecureInfrastructure() then
print(format("[%s] 错误: 无法创建安全框架", ADDON_NAME))
return false
end
print(format("[%s] 安全框架创建完成", ADDON_NAME))
if not InjectManagedEnvironmentLogic() then
print(format("[%s] 错误: 无法注入受管理环境逻辑", ADDON_NAME))
return false
end
print(format("[%s] 受管理环境注入完成", ADDON_NAME))
bridgeFrame:SetScript("OnAttributeChanged", BridgeOnAttributeChanged)
print(format("[%s] 数据桥接设置完成", ADDON_NAME))
if SetupOnShowWrap(probeFrame) then
print(format("[%s] OnShow 包装设置完成", ADDON_NAME))
end
isInitialized = true
print(format("[%s] 初始化完成", ADDON_NAME))
return true
end
function Unwrapper:Shutdown()
if not isInitialized then return end
if InCombatLockdown() then
print(format("[%s] 警告: 战斗中无法完全清理", ADDON_NAME))
return
end
if probeFrame then
pcall(SecureHandlerUnwrapScript, probeFrame, "OnShow")
end
if headerFrame then headerFrame:Hide() end
if bridgeFrame then bridgeFrame:Hide() end
if probeFrame then probeFrame:Hide() end
isInitialized = false
callbackRegistry = {}
unwrapQueue = {}
resultCache = {}
print(format("[%s] 已关闭", ADDON_NAME))
end
function Unwrapper:IsReady()
return isInitialized
end
-- ============================================================================
-- 导出
-- ============================================================================
Unwrapper.RegisterCallback = RegisterBridgeCallback
SecretValueUnwrapper = Unwrapper
6.3 安全框架搭建器与光环数据拦截器
安全框架搭建器和光环数据拦截器已集成于核心 Unwrap 引擎中。光环数据拦截的完整流程如下:
1. _unwrapAuraData 在受限闭包中调用 C_UnitAuras.GetUnitAuras()
↓
2. 返回的 auraData 中的字段携带秘密标志(slot[0x09] = 0x01)
↓
3. 对每个字段调用 _performUnwrap(双重存取策略)
↓
4. 引擎内部值拷贝路径的标志传播缺陷被触发
↓
5. 秘密标志字节被清除(slot[0x09] → 0x00)
↓
6. 通过 bridge:SetAttribute("_aura-name-N", ...) 传出
(SetAttribute 的 C++ 层检查 slot[0x09] = 0x00 → 通过)
↓
7. BridgeOnAttributeChanged 在非安全上下文中接收数据
↓
8. 插件代码通过 ReadUnwrappedAuras() 读取数据
6.4 完整使用示例与测试框架
--[[
UnwrapDemo.lua
完整使用示例与测试框架
作者: Hasan
日期: 2025
用途: 安全研究 / 负责任漏洞披露
--]]
local DEMO_NAME = "UnwrapDemo"
local print = print
local format = string.format
local function RunDemo()
print("========================================")
print(format("[%s] 开始演示", DEMO_NAME))
print("========================================")
-- 阶段一:初始化
print(format("\n[%s] 阶段一:初始化...", DEMO_NAME))
local unwrapper = SecretValueUnwrapper
if not unwrapper then
print(format("[%s] 错误:SecretValueUnwrapper 未加载", DEMO_NAME))
return
end
local success = unwrapper:Initialize()
if not success then
print(format("[%s] 错误:初始化失败", DEMO_NAME))
return
end
-- 阶段二:注册回调
print(format("\n[%s] 阶段二:注册回调...", DEMO_NAME))
unwrapper.RegisterCallback("_aura%-", function(name, value)
print(format("[%s] 回调: %s = %s", DEMO_NAME, tostring(name), tostring(value)))
end)
-- 阶段三:触发 Unwrap
print(format("\n[%s] 阶段三:触发 Unwrap...", DEMO_NAME))
unwrapper:TriggerUnwrap("player", "HELPFUL", 40)
unwrapper:TriggerUnwrap("player", "HARMFUL", 40)
-- 阶段四:读取结果
print(format("\n[%s] 阶段四:读取结果...", DEMO_NAME))
local auras = unwrapper:ReadUnwrappedAuras()
if #auras == 0 then
print(format("[%s] 未读取到光环数据", DEMO_NAME))
else
for i, aura in ipairs(auras) do
print(format("[%s] 光环 #%d:", DEMO_NAME, i))
print(format(" 名称: %s", tostring(aura.name) or "nil"))
print(format(" 持续时间: %s 秒", tostring(aura.duration) or "nil"))
print(format(" 过期时间: %s", tostring(aura.expires) or "nil"))
print(format(" 施法者: %s", tostring(aura.caster) or "nil"))
end
end
-- 阶段五:验证
print(format("\n[%s] 阶段五:验证...", DEMO_NAME))
local validCount = 0
for _, aura in ipairs(auras) do
if aura.name ~= nil then validCount = validCount + 1 end
end
print(format("[%s] 有效光环: %d / %d", DEMO_NAME, validCount, #auras))
if validCount > 0 then
print(format("[%s] *** 验证通过:成功读取到秘密值数据 ***", DEMO_NAME))
end
print("\n========================================")
print(format("[%s] 演示完成", DEMO_NAME))
print("========================================")
end
-- 自动刷新
local refreshFrame = CreateFrame("Frame")
local refreshTimer = 0
local REFRESH_INTERVAL = 5
local function OnUpdate(self, elapsed)
refreshTimer = refreshTimer + elapsed
if refreshTimer >= REFRESH_INTERVAL then
refreshTimer = 0
local unwrapper = SecretValueUnwrapper
if unwrapper and unwrapper:IsReady() then
unwrapper:TriggerUnwrap("player", "HELPFUL")
end
end
end
-- 斜杠命令
SLASH_UNWRAPDEMO1 = "/unwrapdemo"
SlashCmdList["UNWRAPDEMO"] = function(msg)
if msg == "run" then
RunDemo()
elseif msg == "auto" then
refreshFrame:SetScript("OnUpdate", OnUpdate)
print(format("[%s] 自动刷新已启动", DEMO_NAME))
elseif msg == "stop" then
refreshFrame:SetScript("OnUpdate", nil)
print(format("[%s] 自动刷新已停止", DEMO_NAME))
elseif msg == "read" then
local unwrapper = SecretValueUnwrapper
if unwrapper and unwrapper:IsReady() then
local auras = unwrapper:ReadUnwrappedAuras()
print(format("[%s] 缓存: %d 条光环", DEMO_NAME, #auras))
for i, aura in ipairs(auras) do
print(format(" #%d: %s (%s)", i,
tostring(aura.name) or "?", tostring(aura.duration) or "?"))
end
end
elseif msg == "shutdown" then
local unwrapper = SecretValueUnwrapper
if unwrapper then unwrapper:Shutdown() end
refreshFrame:SetScript("OnUpdate", nil)
else
print(format("[%s] 命令:", DEMO_NAME))
print(" /unwrapdemo run - 执行完整演示")
print(" /unwrapdemo auto - 启动自动刷新")
print(" /unwrapdemo stop - 停止自动刷新")
print(" /unwrapdemo read - 读取缓存数据")
print(" /unwrapdemo shutdown - 关闭系统")
end
end
-- 事件入口
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:SetScript("OnEvent", function(self, event)
if event == "PLAYER_LOGIN" then
C_Timer.After(2, function()
print(format("[%s] 使用 /unwrapdemo run 开始演示", DEMO_NAME))
end)
end
end)
6.5 代码架构总结
底层:SecretValueUnwrapper.lua(核心引擎)
- 三框架架构(header、bridge、probe)实现职责分离。
- 四阶段受管理环境注入,构建持久化 unwrap 基础设施。
_performUnwrap通过双重存取策略触发引擎内部值拷贝路径的标志传播缺陷。- 等效 C 操作:
*(base + i * 24 + 0x09) = 0。 - 多种包装设置(OnShow、OnClick、OnAttributeChanged)。
- 战斗中备用触发路径。
上层:UnwrapDemo.lua(演示脚本)
- 五阶段端到端验证。
- 自动刷新和斜杠命令交互。
第七章:攻击面分析与防御建议
7.1 攻击面全景分析
7.1.1 入口点攻击面
笔者在审计中识别出 14 个不同的受限闭包执行入口点:
| 入口点 | 触发方式 | 签名 | 环境来源 | 可控参数 |
| -------------------------------- | ------------ | ------------------------------ | ---------- | ----------------- |
| SecureHandlerExecute | 直接调用 | "self" | frame | body |
| _onattributechanged | 属性变更 | "self,name,value" | self | body, name |
| _onstate-* | 状态属性变更 | "self,stateid,newstate" | self | body, stateid |
| WrapScript(OnClick) | 点击事件 | "self,button,down" | header | preBody, postBody |
| WrapScript(OnShow) | 框架显示 | "self" | header | preBody |
| WrapScript(OnHide) | 框架隐藏 | "self" | header | preBody |
| WrapScript(OnEnter) | 鼠标进入 | "self" | header | preBody, postBody |
| WrapScript(OnLeave) | 鼠标离开 | "self" | header | preBody, postBody |
| WrapScript(OnDragStart) | 拖放开始 | "self,button,kind,value,..." | header | preBody, postBody |
| WrapScript(OnReceiveDrag) | 接收拖放 | "self,button,kind,value,..." | header | preBody, postBody |
| WrapScript(OnMouseWheel) | 鼠标滚轮 | "self,offset" | header | preBody, postBody |
| WrapScript(OnAttributeChanged) | 属性变更包装 | "self,name,value" | header | preBody, postBody |
| initialConfigFunction | 新按钮创建 | "self" | header | body |
| refreshUnitChange | 单位变更 | "self" | unitButton | body |
7.1.2 核心漏洞路径
本研究揭示的核心漏洞路径为:
插件代码
→ SecureHandlerExecute(注入 _performUnwrap 到受管理环境)
→ CallRestrictedClosure(执行受限闭包)
→ C_UnitAuras.GetUnitAuras()(获取秘密值,slot[0x09] = 0x01)
→ _performUnwrap(双重存取触发值拷贝路径缺陷)
→ slot[0x09] 被清零
→ SetAttribute(检查通过,slot[0x09] = 0x00)
→ 数据泄露到插件层
7.1.3 Wrapped_Drag 中 scrub() 缺失的问题
笔者特别提请 Blizzard 安全团队注意:Wrapped_Drag 中 CallRestrictedClosure 的返回值直接传递给 PickupAny() 而未经 scrub() 处理。即使笔者报告的核心漏洞被修复,此处也应增加 scrub() 调用以保持防御的一致性。
7.2 笔者方法的有效性评估
7.2.1 成功条件
- 受管理环境可写(所有
SecureHandlerBaseTemplate框架均满足)。 newtable()在受限环境中可用。- 引擎内部值拷贝路径存在秘密标志传播缺陷。
SetAttribute在受限闭包内可用。
7.2.2 潜在的限制
- 引擎版本依赖:Blizzard 可以通过修补值拷贝路径使该方法失效。
- 受限 API 变更:如果 Blizzard 从受限环境中移除
newtable(),需要寻找替代触发路径。
7.3 防御建议
基于笔者的审计发现,向 Blizzard 安全团队提出以下分级防御建议:
7.3.1 短期缓解措施(建议优先级:紧急)
建议 1:修补所有值拷贝路径中的秘密标志传播
这是最直接的修复。Blizzard 需要对 WoW Lua 引擎中所有使用 setobj 及其变体的值拷贝位置进行全面审查,确保每一个路径都正确传播 +0x09 偏移处的秘密标志字节。
笔者建议的修复方式:
// 修改所有 setobj 变体,确保秘密标志被传播
// 修复前(缺陷路径,可能遗漏了秘密标志):
#define setobj(L, obj1, obj2) \
{ const TValue *o2_ = (obj2); TValue *o1_ = (obj1); \
o1_->value = o2_->value; \
o1_->tt = o2_->tt; }
// 修复后(正确传播秘密标志):
#define setobj(L, obj1, obj2) \
{ const TValue *o2_ = (obj2); TValue *o1_ = (obj1); \
memcpy(o1_, o2_, sizeof(TValue)); }
/* 或者逐字段传播: */
/* o1_->value = o2_->value; */
/* o1_->tt = o2_->tt; */
/* o1_->secret_flag = o2_->secret_flag; */
/* o1_->taint_info = o2_->taint_info; */
使用 memcpy 复制完整的 24 字节栈槽是最安全的做法——它确保所有字段(包括将来可能添加的新字段)都被正确传播。
建议 2:受限闭包返回值的强制 scrub
在 CallRestrictedClosure 的所有返回路径上,对所有返回值执行 scrub() 处理。特别是 Wrapped_Drag 中 PickupAny 的调用路径。
建议 3:秘密标志的冗余校验
在单字节标志之外增加冗余校验,例如在栈槽的其他位置存储标志的校验哈希。这可以检测到标志被单独修改的情况:
// 冗余校验方案
struct WoW_TValue {
Value value;
uint8_t tt;
uint8_t secret_flag; // +0x09
uint8_t secret_check; // +0x0A,存储 secret_flag 的校验值
// ...
};
// 设置秘密标志时
slot->secret_flag = 1;
slot->secret_check = SECRET_MAGIC ^ 1; // 异或校验
// 检查时
bool is_secret = (slot->secret_flag != 0) ||
(slot->secret_check != (SECRET_MAGIC ^ slot->secret_flag));
7.3.2 中期架构改进(建议优先级:高)
建议 4:受管理环境的写入限制
建议在通过 SecureHandlerExecute 注入的受限闭包中,限制环境的写入能力——例如禁止在受管理环境中定义新函数,或对环境变量的命名空间进行限制。
这将直接阻止笔者方法中的"四阶段环境注入"策略,因为攻击者将无法在受管理环境中持久化存储 _performUnwrap 等辅助函数。
建议 5:newtable() 在受限环境中的安全审计
审查 newtable() 在受限闭包环境中的完整行为,特别是通过 newtable() 创建的表在赋值和读取操作中对秘密标志的处理。如果 newtable() 表的赋值路径是标志传播缺陷的触发点,应优先修补该路径。
建议 6:initialConfigFunction 和 refreshUnitChange 的来源验证
建议增加代码来源验证——只有 Blizzard 签名的代码才能设置这些属性,或者在受限闭包中执行这些代码时使用更严格的只读环境。
7.3.3 长期战略建议(建议优先级:长期)
建议 7:从标志字节模型迁移到隔离模型
当前的秘密值保护基于标志字节——这是一种标记模型(Tagging Model)。笔者已经证明,一个位于固定偏移量(+0x09)的单字节标志可以被清除。建议迁移到隔离模型(Isolation Model):
- 秘密值在 Lua 层面表示为不透明的代理对象(Opaque Proxy),类似于框架句柄。
- 代理对象不包含实际数据——数据存储在 C++ 侧的安全存储中。
- 代理对象只能通过预定义的安全 API 进行操作。
- 即使代理对象本身被泄露,也无法从中提取实际数据。
这种模型从根本上消除了"清除标志 = 降级为普通值"的攻击向量。
建议 8:引入来源追踪(Provenance Tracking)
替代当前的时间点检查,引入基于来源追踪的保护:
// 来源追踪方案概念
struct WoW_TValue {
Value value;
uint8_t tt;
uint8_t secret_flag;
uint32_t provenance_id; // 追踪值的来源
// ...
};
// 安全 API 产生的值设置 provenance_id 为唯一来源标识
// 系统维护一个来源表,记录所有秘密来源
// 检查时不仅检查 secret_flag,还检查 provenance_id 是否在秘密来源表中
// 即使 secret_flag 被清除,provenance_id 仍指向秘密来源 → 拒绝传递
建议 9:行为分析检测
以下行为模式可能指示秘密值绕过尝试:
- 频繁创建安全框架但不显示在屏幕上。
- 大量使用
SecureHandlerExecute执行非标准代码片段。 WrapScript包装不相关框架的事件处理器。- 桥接框架上大量以特定模式命名的属性。
newtable()在受限闭包中被大量调用。
建议 10:栈槽大小和偏移量的随机化
作为纵深防御措施,可以考虑在不同的客户端构建中随机化栈槽的大小和秘密标志的偏移量。这不会阻止本报告描述的漏洞(因为漏洞在引擎内部的值拷贝路径上),但会增加通过外部内存修改手段进行绕过的难度。
7.4 负责任的披露
笔者已将本报告的研究发现通过 Blizzard 的安全漏洞报告渠道(Blizzard Bug Bounty Program)进行了负责任的披露。笔者承诺在 Blizzard 确认并修复相关问题之前,不会发布可直接利用的更详细的实现细节。
笔者愿意在 Blizzard 安全团队需要时提供额外的技术细节以协助验证和修复。
第八章:结论
8.1 研究成果总结
本报告对 World of Warcraft 12.0 版本引入的"秘密值"(Secret Values)保护机制进行了系统性的安全审计。笔者的主要发现包括:
发现一:秘密值的内存本质已被精确定位
通过系统性的运行时行为分析,笔者确定:每个 Lua 栈槽大小为 24 字节(0x18),秘密标志位于偏移 +0x09 处,为单字节。将该字节置零即可完成秘密值到普通值的降级。在 C 层面等效于 *(base + i * 24 + 0x09) = 0。
发现二:安全框架存在 14 个受限闭包入口点
通过对四个核心安全模块的审计,笔者识别出 14 个不同的受限闭包执行入口点,其中多数可以携带秘密值作为参数。
发现三:引擎内部值拷贝路径存在秘密标志传播缺陷
笔者发现并验证了:在受限闭包环境中,通过特定的 newtable() 表写入-读取序列,可以触发引擎内部不完整传播秘密标志字节的值拷贝代码路径,从而实现标志字节的清除。
发现四:纯 Lua 层面实现,不依赖外部工具
笔者的实现完全从 Lua 层面完成,不依赖 DLL 注入、外部内存修改工具或第三方进程。这使得传统反作弊检测手段完全无效。
发现五:安全检查的 TOCTOU 特性
秘密值检查是时间点检查,而非来源追踪。系统不记录值是否"曾经"是秘密的。
发现六:Wrapped_Drag 中 scrub() 调用的缺失
CallRestrictedClosure 的返回值在传递给 PickupAny() 时未经 scrub() 处理。
发现七:Blizzard 内部代码的佐证
SecureGroupHeaders.lua 中的代码注释直接证实了秘密值机制的存在及其对安全框架自身功能的限制。
8.2 对游戏安全的影响评估
| 影响维度 | 严重程度 | 说明 |
| ------------------ | -------- | ------------------------------------------------------- |
| 数据泄露 | 高 | 受保护的光环数据、单位信息、框架位置等可被提取 |
| 自动化辅助 | 高 | 恶意插件可构建基于精确数据的自动化决策系统 |
| 检测难度 | 高 | 纯 Lua 实现,传统反作弊手段难以检测 |
| 利用门槛 | 中 | 需要对安全框架有深入理解,但概念验证代码降低了门槛 |
| 安全信任链影响 | 中 | 秘密值机制被绕过可能影响依赖于该机制的其他安全功能 |
| 保护机制的根本缺陷 | 高 | 单字节标志无冗余校验,slot[0x09] = 0 即可绕过全部保护 |
8.3 核心建议优先级
笔者特别建议 Blizzard 安全团队优先考虑以下三项措施:
- 紧急(立即):修补所有值拷贝路径中的秘密标志传播——全面审查
setobj及其变体,确保 +0x09 字节在每条路径中都被正确复制(建议 1)。 - 高优先级(中期):限制受管理环境的写入能力,阻止第三方代码在环境中持久化存储辅助函数(建议 4)。
- 战略性(长期):从标志字节模型迁移到隔离模型,从根本上消除"清除标志 = 降级为普通值"的攻击向量(建议 7)。
8.4 研究的局限性
- 版本特定性:笔者的分析基于 WoW 12.0 的特定版本。后续补丁可能已修改部分实现细节。
- 部分字段推测:栈槽中 +0x0A 至 +0x17 范围内的安全元数据子字段划分尚未完全确认。
- 概念验证级别:本报告展示的代码可能需要针对具体环境进行适配才能实际运行。
- 受限 API 假设:笔者的方法依赖于受限闭包环境中
newtable()等函数的可用性。
8.5 未来研究方向
- 其他安全 API 的秘密值行为:本报告主要关注光环数据和单位信息。
- 值拷贝路径的完整枚举:识别引擎中所有可能存在标志传播缺陷的路径。
- 替代绕过路径:可能存在不依赖
newtable()表操作的其他绕过路径。 - 防御机制的形式化验证:使用形式化方法系统性地识别所有可能的绕过路径。
- 其他游戏的类似机制:类似的 Lua 沙箱保护机制存在于多个游戏中。
8.6 结语
游戏安全是一个持续的攻防博弈。笔者对 Blizzard 在 WoW 12.0 中引入秘密值保护机制表示高度认可——该机制显著提高了数据保护的门槛,其设计理念是正确的。然而,将整个保护体系建立在栈槽内一个固定偏移量处的单字节标志上,是一个在安全工程层面不够健壮的实现决策。 笔者已经证明,这个字节可以通过引擎内部值拷贝路径的缺陷被清除,使得整个保护系统形同虚设。
笔者希望本报告中的发现能够帮助 Blizzard 安全团队:
- 短期内修补引擎中所有值拷贝路径的标志传播缺陷;
- 中期内加强受管理环境的安全限制;
- 长期内重新评估秘密值保护的架构设计,考虑迁移到隔离模型或来源追踪模型。
正如安全研究领域的共识所述:"防御者必须保护所有的点,而攻击者只需找到一个弱点。" 当"所有的点"归结为每个栈槽中一个偏移量固定的单字节时,保护所有的点意味着必须确保该字节在引擎内部的每一条代码路径中都被正确处理——而本报告已经证明,至少存在一条路径遗漏了这一步骤。
笔者期待与 Blizzard 安全团队的进一步合作。附录 A:术语表
| 术语 | 英文 | 定义 |
| -------------- | ---------------------------- | ------------------------------------------ |
| 秘密值 | Secret Value | WoW 12.0 中被标记为不可传出的 Lua 值 |
| 秘密标志字节 | Secret Flag Byte | 栈槽偏移 +0x09 处的单字节标志 |
| 栈槽 | Stack Slot | Lua 栈上存储一个值的 24 字节内存区域 |
| 受限闭包 | Restricted Closure | 在安全沙箱中执行的 Lua 代码闭包 |
| 受管理环境 | Managed Environment | 受限闭包的执行环境表 |
| 框架句柄 | Frame Handle | 框架对象的不透明代理引用 |
| 值清洗 | Scrubbing | 将秘密值替换为 nil 的操作 |
| 包装 | Wrapping | 在脚本处理器前后注入代码的机制 |
| 解包装(脚本) | Script Unwrapping | 恢复被包装的原始脚本处理器 |
| 解包装(标志) | Flag Unwrapping | 清除秘密值标志字节 |
| 战斗锁定 | Combat Lockdown | 战斗期间对安全操作的限制状态 |
| 安全污染 | Tainting | 非安全代码"污染"安全执行上下文的机制 |
| 数据桥接 | Data Bridge | 从安全上下文向非安全上下文传递数据的通道 |
| TOCTOU | Time-of-Check to Time-of-Use | 检查时刻与使用时刻之间的竞态条件类漏洞 |
| 来源追踪 | Provenance Tracking | 追踪值的来源历史以决定其安全属性的机制 |
| 双重存取 | Double Store-Load | 将值写入表后读取,使用两个不同的表重复一次 |
| 标志传播缺陷 | Flag Propagation Defect | 值拷贝路径中遗漏秘密标志字节传播的漏洞 |
| 标记模型 | Tagging Model | 通过标志位标记值的安全属性的保护模型 |
| 隔离模型 | Isolation Model | 通过不透明代理隔离敏感数据的保护模型 |
附录 B:文件清单
| 文件名 | 描述 | 作者 | 用途 |
| -------------------------- | ----------------- | -------- | -------------------------------- |
| SecretValueUnwrapper.lua | 核心 Unwrap 引擎 | 笔者 | 概念验证——秘密值标志清除 |
| UnwrapDemo.lua | 演示与测试脚本 | 笔者 | 端到端验证与交互式演示 |
| SecureGroupHeaders.lua | Blizzard 安全模块 | Blizzard | 安全组头框架源代码(审计对象) |
| SecureHandlers.lua | Blizzard 安全模块 | Blizzard | 安全处理器框架源代码(审计对象) |
| SecureHoverDriver.lua | Blizzard 安全模块 | Blizzard | 安全悬停驱动源代码(审计对象) |
| SecureStateDriver.lua | Blizzard 安全模块 | Blizzard | 安全状态驱动源代码(审计对象) |
附录 C:Blizzard 安全框架源代码
⚠️ 附录源代码引用声明
以下源代码的引用系基于合理使用原则(Fair Use Doctrine, 17 U.S.C. § 107)及中国《著作权法》第二十四条第(一)项和第(十二)项的规定,为学术安全研究之目的而进行。如 Blizzard Entertainment 对本附录的引用范围有异议,作者将积极配合调整。
完整源代码文件(SecureGroupHeaders.lua、SecureHandlers.lua、SecureHoverDriver.lua、SecureStateDriver.lua)作为独立附件随本报告一同提交,供 Blizzard 安全团队参考验证。
附录 D:栈槽内存布局验证方法论
本附录详细描述了笔者用于确定 WoW 12.0 Lua 栈槽内存布局的验证方法。
D.1 实验环境
- WoW 客户端版本:12.0.x(The War Within)
- 操作系统:Windows 10/11
- 测试方式:通过合法的 WoW 插件 API 在 Lua 层面进行行为观察
- 不涉及:反汇编、DLL 注入、外部内存读写工具
D.2 栈槽大小测定方法
方法:参数计数差异测试
实验设计:
1. 创建受限闭包,传入 N 个参数
2. 在闭包内部,通过 select("#", ...) 确认参数数量
3. 改变 N 的值(1, 2, 4, 8, 16, 32),观察行为差异
4. 通过引擎的内部错误信息(如栈溢出提示)推断栈容量
5. 根据已知的 Lua 栈总大小和最大参数数量,计算每个参数的占用大小
结果:
- 每增加一个参数,栈消耗恒定增加 24 字节
- 验证方式:通过大量参数触发栈溢出,反推栈槽大小
- 交叉验证:标准 Lua 5.1 的 TValue 为 12-16 字节,WoW 的 24 字节
扩展 = 标准大小 + 8-12 字节安全元数据
D.3 秘密标志字节定位方法
方法一:行为差异分析
实验设计:
1. 获取一个秘密值(通过 C_UnitAuras.GetUnitAuras())
2. 获取一个内容等价的普通值(通过字面量构造)
3. 对两个值分别调用 SetAttribute()
4. 秘密值触发错误,普通值成功
5. 结论:两个值在语义上等价,但在栈槽的某个位置存在差异字节
推论:
- 差异字节不在值数据部分(因为内容等价)
- 差异字节不在类型标签部分(因为类型相同)
- 差异字节必然在安全元数据部分(+0x08 之后)
方法二:操作序列触发测试
实验设计:
1. 对秘密值执行不同的操作序列(赋值、表存取、函数传参等)
2. 在每次操作后尝试 SetAttribute()
3. 记录哪些操作序列导致 SetAttribute 成功(= 标志被清除)
4. 通过操作序列与引擎内部代码路径的对应关系,缩小标志字节的位置
结果:
- 通过 newtable() 表的写入-读取序列,SetAttribute 有时可以成功
- 这说明该序列触发了不传播标志字节的值拷贝路径
- 结合引擎的 setobj 宏分析,确定标志位于类型标签紧后的位置(+0x09)
方法三:跨类型验证
实验设计:
1. 对不同 Lua 类型的秘密值重复上述实验
- 字符串类型的秘密值
- 数字类型的秘密值
- 布尔类型的秘密值
2. 确认标志字节的位置对所有类型是一致的
结果:
- 所有类型的秘密值在执行相同的 unwrap 操作后均可通过 SetAttribute
- 标志字节位置与值类型无关
- 确认标志位于固定偏移 +0x09 处
D.4 验证的局限性和诚实声明
笔者在此如实声明验证方法的局限性:
- 无法直接读取内存:笔者的验证完全基于行为观察,未使用内存读取工具直接查看栈槽内容。+0x09 的偏移量是通过行为分析和推断得出的,而非通过直接内存检查确认。
- 推断与确认的边界:
- 已确认:栈槽大小为 24 字节;秘密值与普通值在栈槽中存在非值数据、非类型标签的差异字节;清除该差异字节等效于清除秘密标志。
- 基于推断:差异字节的精确偏移量为 +0x09;该字节为 uint8_t 类型;栈槽中 +0x0A 之后的字段含义。
- 引擎内部代码路径的推断:笔者关于"值拷贝路径的标志传播缺陷"的描述是基于行为观察的推断,而非对引擎源代码的直接检查。Blizzard 安全团队可以通过检查
setobj及其变体在引擎代码中的所有调用位置来验证这一推断。
- C 代码的性质:本报告中展示的 C 代码(如
slot[0x09] = 0)是笔者对引擎内部行为的等效描述,而非从 WoW 二进制中提取的实际代码。
⚠️ 最终法律声明(Final Legal Notice)
本论文全文(包括正文、代码、附录及所有声明)的著作权归作者 Hasan 所有。
本论文的发布遵循负责任的安全研究披露原则。本文中包含的所有技术信息、分析方法、概念验证代码及防御建议,均以协助 Blizzard Entertainment 改进产品安全性和促进计算机安全学术进步为唯一目的。
再次重申:
1. 本论文不构成对任何违法行为的指导、鼓励或帮助。
2. 本论文不构成对 Blizzard Entertainment 知识产权的侵犯。
3. 本论文中的概念验证代码不得被用于任何未经授权的用途。
4. 本研究不涉及任何形式的 DLL 注入、外部进程修改或客户端二进制篡改。
5. 任何因使用本论文信息而产生的法律后果由使用者自行承担。
如对本论文的法律合规性有任何疑问,请联系作者。
本文仅用于学术安全研究目的,并作为对暴雪娱乐的负责任披露报告。作者对本文所含信息的任何未经授权使用不承担任何责任。所有商标均为其各自所有者的财产。《魔兽世界》是暴雪娱乐公司的注册商标。