跳过正文
  1. SCUM/

SCUM 人物创建技能校验漏洞:负经验值与分组伪造

目录

在分析 SCUM 人物创建流程时,我发现了两项技能校验缺陷:负经验值可以抵消正常技能消耗,客户端提交的技能分组也可以决定从哪一项属性预算中扣除点数。

前者破坏了技能消耗应当非负的计算前提,后者让客户端影响了本应由服务端规则确定的技能归属。两项问题可以独立触发,也可以组合,使正常人物创建界面无法生成的技能配置通过校验。这次漏洞于 8 月 25 日修复,文末记录了提交、确认、修复及测试账号的后续经过。

漏洞表现
#

正常创建人物时,力量、体质、敏捷和智力分别对应一组技能预算。同一分组内的技能需要共用这部分点数,因此即使人物还有其他属性的剩余预算,也不能随意挪用。

下面是测试过程中记录的异常表现:

测试角色的基础属性均为 3.0,多项技能显示百分之百,选中的高级技能经验接近上限

图:四项基础属性均为 3.0,多项技能显示 100%;选中的高级技能经验值为 9,999,999,接近 10,000,000 的等级上限。

截图展示了异常技能数据在界面上的表现。判断它为什么能够通过人物创建校验,还需要结合请求字段、技能消耗公式和服务端的实际判断分支。

人物创建数据如何进入服务端
#

与这次问题有关的数据流可以整理为:

1客户端填写人物模板
23序列化人物创建请求(消息类型 9)
45服务端反序列化人物模板
67检查技能等级、经验值与预算
89创建角色并写入档案

人物创建请求的高层结构如下。代码保留必要的字段名称,注释说明其含义:

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,0001 + 2 × 经验值 ÷ 100,000
中级1,000,0003 + 3 × 经验值 ÷ 1,000,000
高级10,000,0006 + 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。两项同属体质的技能可以形成下面的组合:

技能分组等级经验值计算结果
跑步039,999,999消耗约 10 点,单独已经超出 6 点预算
耐力03−100,000,000消耗为 −34 点,增加剩余预算

其余技能保持零等级、零经验时,体质分组剩余预算约为:

16 − 10 − (−34) = 30

最终检查发生在技能遍历结束后。中间出现的超额消耗,被后续负消耗抵消,最后的剩余预算又回到了允许范围。

浮点数边界需要逐个区分
#

合法经验值应同时满足:

1std::isfinite(experience) &&
2experience >= 0.0f &&
3experience < experienceLimit

不能把负数、正无穷大、负无穷大和非数值一概视为相同情况:

  • 有限负数: 可以通过现有上界比较;足够大的负值会产生负消耗,这是本文计算示例使用的路径。
  • 正无穷大: 大于经验上限,会被现有上界比较拒绝,不能宣称它也能直接绕过。
  • 负无穷大: 不会触发这条上界拒绝分支。按所见算术路径推导,它可能产生负无穷消耗和正无穷剩余预算;这属于静态推导,不是特殊浮点输入的完整运行复现。
  • 非数值: 浮点比较会出现无序结果。即使通过了经验上界比较,仍须检查后续分支;在合法分组槽位内,它污染的分类预算会被原始末尾检查拒绝。

汇编与反编译证据
#

下面两段汇编取自当时保存的反汇编记录。指令和寄存器保留原样,地址列及地址生成的标签统一以 xxxxx 遮盖。30h34h38h 等结构体字段偏移和对象步长保留,它们用于说明数据如何被访问。

等级检查与经验值上界比较
#

 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,计算出的上限位于 xmm4comiss 比较二者,后面的 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

这段指令直接展示了两个关键事实:

  1. movzx eax, byte ptr [rsi] 读取分组字段,[rcx+rax*4] 随即用它选择预算槽位;在这一过程中没有先按技能标识派生真实分组。
  2. 技能消耗位于 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 日测试账号解封。

账号封禁与解封作为后续事件记录,现有材料没有说明封禁原因。

这次分析涉及两项需要由服务端保证的约束:技能消耗必须来自合法数值,技能所属分组必须来自服务端规则。只有先建立这两个前提,分类预算与总预算的最终比较才有意义。