在分析 SCUM 人物创建流程时,我发现了两项技能校验缺陷:负经验值可以抵消正常技能消耗,客户端提交的技能分组也可以决定从哪一项属性预算中扣除点数。
前者破坏了技能消耗应当非负的计算前提,后者让客户端影响了本应由服务端规则确定的技能归属。两项问题可以独立触发,也可以组合,使正常人物创建界面无法生成的技能配置通过校验。这次漏洞于 8 月 25 日修复,文末记录了提交、确认、修复及测试账号的后续经过。
漏洞表现#
正常创建人物时,力量、体质、敏捷和智力分别对应一组技能预算。同一分组内的技能需要共用这部分点数,因此即使人物还有其他属性的剩余预算,也不能随意挪用。
下面是测试过程中记录的异常表现:

图:四项基础属性均为 3.0,多项技能显示 100%;选中的高级技能经验值为 9,999,999,接近 10,000,000 的等级上限。
截图展示了异常技能数据在界面上的表现。判断它为什么能够通过人物创建校验,还需要结合请求字段、技能消耗公式和服务端的实际判断分支。
人物创建数据如何进入服务端#
与这次问题有关的数据流可以整理为:
1客户端填写人物模板
2 ↓
3序列化人物创建请求(消息类型 9)
4 ↓
5服务端反序列化人物模板
6 ↓
7检查技能等级、经验值与预算
8 ↓
9创建角色并写入档案人物创建请求的高层结构如下。代码保留必要的字段名称,注释说明其含义:
1struct CreateUserProfileRequest
2{
3 FString UserDisplayName; // 用户显示名
4 FCharacterTemplate CharacterTemplate; // 人物模板,包含技能数组
5 uint8_t HasUsedNewPlayerProtection; // 新手保护标记
6 uint8_t EnableDigitalDeluxeBiomeSuit; // 特定服装选项标记
7};每项技能与本漏洞相关的序列化字段为:
1struct SerializedSkillTemplate
2{
3 uint8_t Attribute; // 客户端声明的技能分组
4 FString ClassName; // 技能标识
5 uint8_t Level; // 技能等级
6 float Experience; // 当前等级内的经验值
7};这里描述的是字段的序列化顺序。内存中的技能对象大小为 38h,网络数据按归档规则逐项读写,不能直接用整个内存结构的复制结果代替。
其中,ClassName 用于标识技能,Attribute 决定预算槽位。二者之间的对应关系本应由服务端技能定义确定。预算槽位的含义如下:
| 分组值 | 对应属性 | 初始技能预算 |
|---|---|---|
| 0 | 体质 | 体质 × 2 |
| 1 | 敏捷 | 敏捷 × 2 |
| 2 | 智力 | 智力 × 2 |
| 3 | 力量 | 力量 × 2 |
服务端原有校验逻辑#
以下为还原后的核心伪代码,变量名作了可读性整理。它聚焦人物基础属性、技能数据和预算检查;其他模板检查仍属于完整创建流程的一部分。
1bool ValidateSkills(const FCharacterTemplate& character)
2{
3 // 原有逻辑仍检查四项基础属性及总分配值。
4 const float strength = character.Strength;
5 const float constitution = character.Constitution;
6 const float dexterity = character.Dexterity;
7 const float intelligence = character.Intelligence;
8
9 if (strength < 0.9999f || strength > 5.0001f) return false;
10 if (constitution < 0.9999f || constitution > 5.0001f) return false;
11 if (dexterity < 0.9999f || dexterity > 5.0001f) return false;
12 if (intelligence < 0.9999f || intelligence > 5.0001f) return false;
13
14 if ((constitution + strength) +
15 (intelligence + dexterity) > 12.1f)
16 {
17 return false;
18 }
19
20 float budget[4] =
21 {
22 character.Constitution * 2.0f, // 体质
23 character.Dexterity * 2.0f, // 敏捷
24 character.Intelligence * 2.0f, // 智力
25 character.Strength * 2.0f // 力量
26 };
27
28 float totalBudget =
29 (budget[2] + budget[1]) + (budget[3] + budget[0]);
30
31 for (const FSkillTemplate& skill : character.Skills)
32 {
33 const uint8_t level = skill.Level;
34 if (level >= 4)
35 return false;
36
37 const float experienceLimit =
38 powf(10.0f, level + 4.0f);
39
40 // 只比较上界,未先检查非负性与有限性。
41 if (skill.Experience >= experienceLimit)
42 return false;
43
44 const float completedLevelCost =
45 level * (level + 1) / 2.0f;
46
47 const float currentProgressCost =
48 (skill.Experience / experienceLimit) * (level + 1);
49
50 const float cost =
51 completedLevelCost + currentProgressCost;
52
53 // 直接用客户端提交的分组选择预算槽位。
54 budget[skill.Attribute] -= cost;
55 totalBudget -= cost;
56 }
57
58 // 保留原始反编译中的比较方向,不能随意改成 <。
59 for (float remaining : budget)
60 {
61 if (!(remaining >= -0.1f))
62 return false;
63 }
64
65 if (totalBudget < -0.1f)
66 return false;
67
68 return true;
69}技能点消耗由“已取得等级的固定消耗”和“当前等级内经验进度的消耗”组成:
| 技能等级 | 经验值上限,不含边界 | 技能点消耗 |
|---|---|---|
| 无技能 | 10,000 | 经验值 ÷ 10,000 |
| 初级 | 100,000 | 1 + 2 × 经验值 ÷ 100,000 |
| 中级 | 1,000,000 | 3 + 3 × 经验值 ÷ 1,000,000 |
| 高级 | 10,000,000 | 6 + 4 × 经验值 ÷ 10,000,000 |
更高的技能等级会被创建阶段的等级检查拒绝。每个属性分组的剩余预算与总剩余预算在最后统一检查,并允许约 0.1 点的浮点计算误差。
漏洞一:负经验值抵消技能消耗#
负数可以通过上界比较#
原有经验值检查等价于:
1if (skill.Experience >= experienceLimit)
2 return false;负数小于这里的正数上限,因此不会被这条比较拒绝。它随后进入技能消耗公式,降低当前等级进度对应的消耗。
负经验值会降低消耗,但不一定让总消耗立即变成负数。 对已经包含固定等级消耗的技能,需要负值的绝对值足够大,才能把该技能的总消耗拉到零以下。
一个高级技能如何增加 34 点预算#
以等级为 3 的高级技能为例,报告中的有限负数示例可以按以下代码理解:
1const int level = 3;
2const float experience = -100000000.0f;
3const float experienceLimit = 10000000.0f;
4
5const float completedLevelCost =
6 level * (level + 1) / 2.0f; // 6
7
8const float progressCost =
9 (experience / experienceLimit) *
10 (level + 1); // -40
11
12const float cost =
13 completedLevelCost + progressCost; // -34
14
15budget[attribute] -= cost; // 减去 -34,等于增加 34
这个技能在预算计算中产生了负消耗。服务端从预算里减去负数,反而增加了剩余点数。
假设人物四项基础属性均为 3,那么体质分组的初始预算为 6。两项同属体质的技能可以形成下面的组合:
| 技能 | 分组 | 等级 | 经验值 | 计算结果 |
|---|---|---|---|---|
| 跑步 | 0 | 3 | 9,999,999 | 消耗约 10 点,单独已经超出 6 点预算 |
| 耐力 | 0 | 3 | −100,000,000 | 消耗为 −34 点,增加剩余预算 |
其余技能保持零等级、零经验时,体质分组剩余预算约为:
16 − 10 − (−34) = 30最终检查发生在技能遍历结束后。中间出现的超额消耗,被后续负消耗抵消,最后的剩余预算又回到了允许范围。
浮点数边界需要逐个区分#
合法经验值应同时满足:
1std::isfinite(experience) &&
2experience >= 0.0f &&
3experience < experienceLimit不能把负数、正无穷大、负无穷大和非数值一概视为相同情况:
- 有限负数: 可以通过现有上界比较;足够大的负值会产生负消耗,这是本文计算示例使用的路径。
- 正无穷大: 大于经验上限,会被现有上界比较拒绝,不能宣称它也能直接绕过。
- 负无穷大: 不会触发这条上界拒绝分支。按所见算术路径推导,它可能产生负无穷消耗和正无穷剩余预算;这属于静态推导,不是特殊浮点输入的完整运行复现。
- 非数值: 浮点比较会出现无序结果。即使通过了经验上界比较,仍须检查后续分支;在合法分组槽位内,它污染的分类预算会被原始末尾检查拒绝。
汇编与反编译证据#
下面两段汇编取自当时保存的反汇编记录。指令和寄存器保留原样,地址列及地址生成的标签统一以 xxxxx 遮盖。30h、34h、38h 等结构体字段偏移和对象步长保留,它们用于说明数据如何被访问。
等级检查与经验值上界比较#
1; 技能预算校验函数:sub_xxxxx
2xxxxx movzx eax, byte ptr [rsi+30h] ; 读取技能等级
3xxxxx cmp al, 4
4xxxxx jnb loc_xxxxx ; 等级不小于 4,拒绝
5xxxxx movd xmm6, eax
6xxxxx movaps xmm0, xmm9
7xxxxx cvtdq2ps xmm6, xmm6
8xxxxx mov ebx, eax
9xxxxx movaps xmm1, xmm6
10xxxxx addss xmm1, xmm8
11xxxxx call powf ; 计算该等级的经验上限
12xxxxx movss xmm3, dword ptr [rsi+34h]; 读取客户端经验值
13xxxxx movaps xmm4, xmm0 ; 保存经验上限
14xxxxx comiss xmm3, xmm4
15xxxxx jnb loc_xxxxx ; 经验值不小于上限,拒绝rsi 指向当前技能对象,等级位于对象内偏移 30h,经验值位于 34h。经验值被读入 xmm3,计算出的上限位于 xmm4;comiss 比较二者,后面的 jnb 在普通有序比较下对应“大于或等于”。
因此,有限负经验值不会触发这里的拒绝分支。结合完整函数的反编译记录,在已分析路径中未观察到进入消耗计算前的零下界和有限性检查。
客户端分组直接参与索引与扣减#
1xxxxx mov rax, [rsp+0A8h+var_78]
2xxxxx addss xmm6, xmm10
3xxxxx divss xmm3, xmm4 ; 经验值除以经验上限
4xxxxx test rax, rax
5xxxxx lea rcx, [rsp+0A8h+var_88]
6xxxxx cmovnz rcx, rax ; 取得预算数组
7xxxxx movzx eax, byte ptr [rsi] ; 读取客户端声明的分组
8xxxxx add rsi, 38h ; 指向下一项技能
9xxxxx movss xmm0, dword ptr [rcx+rax*4]
10xxxxx mulss xmm3, xmm6 ; 经验进度对应的消耗
11xxxxx addss xmm3, xmm2 ; 加上等级基础消耗
12xxxxx subss xmm0, xmm3 ; 扣减当前分组预算
13xxxxx subss xmm7, xmm3 ; 扣减总预算
14xxxxx movss dword ptr [rcx+rax*4], xmm0这段指令直接展示了两个关键事实:
movzx eax, byte ptr [rsi]读取分组字段,[rcx+rax*4]随即用它选择预算槽位;在这一过程中没有先按技能标识派生真实分组。- 技能消耗位于
xmm3,两条subss分别从分类预算和总预算中扣除该值。消耗为负时,两处扣减都会增加剩余预算。
这证明了所分析预算路径中的行为。对其他未追踪的调用路径,不能仅凭这一段指令断言其校验情况。
末尾预算比较不能随意改写#
末尾分支有完整的反编译记录,现有材料没有保留相应的汇编摘录。下面按原始分支整理变量名,并省略临时存储清理:
1int index = 0;
2bool accepted = false;
3
4while (budget[index] >= -0.1f)
5{
6 if (++index >= 4)
7 {
8 if (totalBudget < -0.1f)
9 break;
10
11 accepted = true;
12 break;
13 }
14}
15
16return accepted;分类预算要求 remaining >= -0.1f 成立才能继续。非数值与有序数比较时不满足这个条件,因此会被拒绝。把它改写成“仅当 remaining < -0.1f 时拒绝”,会改变非数值输入的语义:
1// 两种写法对普通有限数一致,对非数值并不等价。
2if (!(remaining >= -0.1f)) return false; // 非数值被拒绝
3if (remaining < -0.1f) return false; // 非数值不触发这一分支
总预算单独使用“小于阈值则拒绝”的比较,但它之前已经有四项分类预算检查。不能只看总预算这一句,就认定非数值经验能够通过整套校验。
这些特殊浮点分支的讨论,以相关运算能够继续执行为前提。原始记录没有运行时浮点异常控制状态,也没有特殊浮点输入的完整运行复现;本文确定的负消耗计算示例使用可正常表示的有限负数。
漏洞二:客户端伪造技能分组#
技能身份与预算归属没有绑定#
技能真实所属的分组属于服务端规则。例如:
| 技能标识 | 技能 | 正确分组 |
|---|---|---|
RunningSkill | 跑步 | 体质,0 |
EnduranceSkill | 耐力 | 体质,0 |
ThieverySkill | 盗窃 | 敏捷,1 |
RiflesSkill | 步枪 | 力量,3 |
但在已分析的预算校验路径中,扣减使用的是客户端提交值:
1budget[skill.Attribute] -= cost;该路径中未观察到根据技能标识查找服务端定义、再核对分组的过程:
1const FServerSkillDefinition* definition =
2 FindServerSkillDefinition(skill.ClassName);
3
4if (definition == nullptr)
5 return false;
6
7if (skill.Attribute != definition->Attribute)
8 return false;这样一来,客户端声明的技能身份与客户端声明的预算分组之间,缺少了服务端规则的约束。
不使用负数,也可以转移消耗#
仍以四项基础属性均为 3 为例,每个分组各有 6 点预算。等级 3、经验 0 的高级技能,每项消耗正好为 6。
正常情况下,跑步与耐力都应从体质预算扣除:
1体质预算:6
2跑步消耗:6
3耐力消耗:6
4剩余预算:6 − 6 − 6 = −6,应拒绝如果客户端把耐力的分组改为敏捷,提交的字段组合就变成:
1// 下列为字段赋值示意,其余人物数据保持合法。
2SerializedSkillTemplate running;
3running.ClassName = TEXT("RunningSkill");
4running.Attribute = 0; // 正确:体质
5running.Level = 3;
6running.Experience = 0.0f;
7
8SerializedSkillTemplate endurance;
9endurance.ClassName = TEXT("EnduranceSkill");
10endurance.Attribute = 1; // 伪造为敏捷,真实分组应为体质
11endurance.Level = 3;
12endurance.Experience = 0.0f;两个技能各自扣减不同的预算槽位:
1体质预算:6 − 6 = 0
2敏捷预算:6 − 6 = 0
3总预算:24 − 12 = 12所有预算都满足最终检查,但人物实际获得了两个本应共同消耗体质预算的高级技能。这条路径不需要负经验值,伪造分组本身就足以破坏分类预算限制。
这里讨论的是把消耗转移到 0 至 3 的有效预算槽位。越界分组属于另外的输入安全问题,不能从这一示例直接推导出任意内存读写或代码执行能力。
两项缺陷组合后的影响#
两项问题可以配合:先通过伪造分组,让多个技能的消耗落到同一个预算槽,再利用该槽位中的负消耗抵消正常消耗。这会扩大能够通过校验的异常技能组合。
但修复时必须分别处理两个根因:
- 只拒绝负经验值,仍不能阻止技能被划入错误分组。
- 只校正技能分组,仍不能阻止正确分组内的负消耗抵扣。
触发条件是能够发起人物创建请求,并改变其中的技能数据,不需要服务端管理权限。主要影响是人物初始技能配置、角色成长和多人游戏公平性。若异常值被写入档案,影响还可能延续到后续加载过程。
现有证据不支持把它描述为任意提高基础属性、设置任意技能等级、接管账户或执行服务端代码。
修复建议与参考伪代码#
修复需要同时约束经验值的取值范围,以及技能身份与所属分组之间的关系。以下代码是基于根因整理的建议方案,不代表官方实际采用的补丁实现。
用服务端定义决定技能分组#
服务端应先通过受信任的技能表查询技能定义。未知技能应被拒绝,预算扣减使用查到的真实分组。
1const FServerSkillDefinition* definition =
2 FindServerSkillDefinition(skill.ClassName);
3
4if (definition == nullptr)
5 return false;
6
7const uint8_t attribute = definition->Attribute;
8if (attribute >= 4)
9 return false;
10
11// 如果为了协议兼容保留客户端分组,也必须验证一致性。
12if (skill.Attribute != attribute)
13 return false;在输入、单项消耗和剩余预算三个层面检查#
下面的参考伪代码保留了完整的预算计算过程,同时加入技能白名单、重复项检查、经验值范围检查和中间结果检查。基础属性与技能集合的辅助校验需要按游戏规则实现,示例只保留调用位置;技能表查询假定返回稳定、唯一的规范技能定义。
1bool ValidateSkillsWithServerRules(const FCharacterTemplate& character)
2{
3 // 辅助校验的具体规则省略,不能只验证预算。
4 if (!ValidateCreationAttributesByServerRules(character))
5 return false;
6 if (!ValidateRequiredSkillSetAndCount(character.Skills))
7 return false;
8
9 double budget[4] =
10 {
11 character.Constitution * 2.0,
12 character.Dexterity * 2.0,
13 character.Intelligence * 2.0,
14 character.Strength * 2.0
15 };
16
17 for (double remaining : budget)
18 {
19 if (!std::isfinite(remaining) || remaining < 0.0)
20 return false;
21 }
22
23 double totalBudget =
24 budget[0] + budget[1] + budget[2] + budget[3];
25 if (!std::isfinite(totalBudget))
26 return false;
27
28 TSet<const FServerSkillDefinition*> seenSkills;
29
30 for (const FSkillTemplate& skill : character.Skills)
31 {
32 const FServerSkillDefinition* definition =
33 FindServerSkillDefinition(skill.ClassName);
34 if (definition == nullptr)
35 return false;
36
37 // 按服务端规范技能定义去重,不按客户端原始名称去重。
38 if (seenSkills.Contains(definition))
39 return false;
40 seenSkills.Add(definition);
41
42 const uint8_t attribute = definition->Attribute;
43 if (attribute >= 4 || skill.Attribute != attribute)
44 return false;
45
46 const uint8_t level = skill.Level;
47 if (level > 3)
48 return false;
49
50 const double experience =
51 static_cast<double>(skill.Experience);
52 if (!std::isfinite(experience) || experience < 0.0)
53 return false;
54
55 const double limit =
56 std::pow(10.0, static_cast<double>(level) + 4.0);
57 if (!std::isfinite(limit) || experience >= limit)
58 return false;
59
60 const double cost =
61 level * (level + 1) / 2.0 +
62 (experience / limit) * (level + 1);
63 if (!std::isfinite(cost) || cost < 0.0)
64 return false;
65
66 // 扣减使用服务端定义中的分组。
67 budget[attribute] -= cost;
68 totalBudget -= cost;
69
70 if (!std::isfinite(budget[attribute]) ||
71 !std::isfinite(totalBudget))
72 {
73 return false;
74 }
75 }
76
77 for (double remaining : budget)
78 {
79 if (!std::isfinite(remaining) || remaining < -0.1)
80 return false;
81 }
82
83 return std::isfinite(totalBudget) && totalBudget >= -0.1;
84}使用更高精度计算不能替代输入校验。对于异常经验值和不一致的分组,应直接拒绝请求;静默把它们截断为某个“正常值”,容易掩盖异常来源,并导致客户端与服务端对人物状态的理解不一致。
角色写入数据库之前还应验证完整模板。导入、恢复等写入入口也应复用数值合法性、技能身份与分组一致性校验,并按对应阶段采用预算与成长规则。
回归测试与既有数据排查#
测试需要分别覆盖两项缺陷,再检查它们的组合情况:
| 测试场景 | 代表输入 | 修复后预期 |
|---|---|---|
| 正常创建 | 规则允许的技能组合 | 接受 |
| 负经验值 | −1、−100,000,000、最小有限负数 | 拒绝 |
| 非有限经验值 | 非数值、正无穷大、负无穷大 | 全部拒绝 |
| 经验值等于上限 | 高级技能经验为 10,000,000 | 拒绝 |
| 上限前的有效值 | 高级技能经验为 9,999,999 | 仅在预算也允许时接受 |
| 分组不一致 | 耐力声明为敏捷分组 | 拒绝 |
| 分组越界 | 4、255 | 拒绝并保持服务端稳定 |
| 未知或重复技能 | 未登记标识、同一技能出现两次 | 拒绝 |
| 技能集合不完整 | 缺少规则要求的技能 | 拒绝 |
| 分类预算超额 | 两个同组高级技能超出分组预算 | 拒绝 |
| 组合输入 | 错误分组与负经验值同时出现 | 拒绝 |
| 既有异常档案 | 负经验、非有限值、分组不一致 | 按明确策略隔离、修复或拒绝加载 |
创建预算复核应针对人物创建记录或可靠的初始角色快照。已经正常成长的角色,应按当前技能状态与成长规则排查,不能因为其技能超过出生时的预算,就直接判定异常。排查项目包括重复技能、错误分组、越界经验和非有限数值;数据处置应保留记录,区分历史数据迁移、意外损坏和主动篡改。
发现与处理经过#
| 日期 | 事件 |
|---|---|
| 8 月 7 日至 8 月 10 日 | 发现漏洞。 |
| 8 月 17 日 | 向官方提交漏洞。 |
| 8 月 17 日 | 官方确认漏洞。 |
| 8 月 25 日 | 漏洞修复。 |
| 8 月 26 日 | 测试账号被封禁。 |
| 8 月 30 日 | 测试账号解封。 |
账号封禁与解封作为后续事件记录,现有材料没有说明封禁原因。
这次分析涉及两项需要由服务端保证的约束:技能消耗必须来自合法数值,技能所属分组必须来自服务端规则。只有先建立这两个前提,分类预算与总预算的最终比较才有意义。