评测机侧信道攻击(面向数据编程,半自动 AC 机)原理解析与防御指南

· · 科技·工程

前言

侧信道攻击(俗称套数据)在日常讨论中偶被提及,但其背后隐藏的漏洞不容小觑。本文旨在深入剖析其技术细节,并为各位出题人敲响警钟,共同提升评测安全性。

免责声明:本文纯属技术与经验分享,请读者切勿在真实比赛中尝试本文所述的任何攻击行为。共同维护良好的学术诚信与评测秩序是每位选手的责任。

这是什么

我们直接以 [Algo Beat 009 & MROI-R1] Parallel Parentheses 作为例子。需要注意的是,虽然这题是通信题,但是侧信道攻击的原理和通信题题型本身无关,在常规开放反馈的评测环境下,该方法对大多数答案信息量小的传统题具有通用性。

首先给出结论,选手在持有能得到 1 分的基准代码的前提下,在理论上仅需极少数次后续提交,便有很大机会获取该测试点的满分答案。

首先提交一次 1 分代码初步测试,发现实际有用的测试点只有 6 个(Subtask #1 前两个就连 1 分代码都能过,所以不考虑),这大概率说明该题目的评测采用了固定数据集。既然每个测试点的输入和答案是静态且唯一的,攻击者就有可能利用评测机的状态反馈进行编码实施攻击。

我们先考虑:当测试数据只有一个测试点的时候,我们会怎么做?

在程序设计竞赛的初学阶段,部分选手可能接触过甚至独立发明过利用各种返回状态尝试套取答案的做法。假设经过上述 1 分暴力代码的计算,我们计算出来的正确答案是 A。那么如果 A \bmod 2 = 1,我们就让代码执行 sleep(CLOCKS_PER_TIME) 强行卡顿一秒(不过要控制好不能超时)。

这样,你观察评测运行结果,如果这个点的运行时间异常增加了一秒钟,说明答案是奇数;如果没有增加,说明是偶数。

你继续这么操作下去,如果 A 满足其二进制下第二位是 1,就继续让代码 sleep,观察是否延迟。这样,你就有了一个相对笨拙的方法去得到答案。

但这让我们实现了从 0 到 1 的突破。 显然,我们没必要一次只传一个 bit。考虑充分利用评测机返回的信息,即程序运行时间程序运行空间(空间的控制可以用 mallocmemset 动态分配来实现)。

该题目的时间限制为 5\text{ s},空间限制为 512\text{ MB}。而我们上面那份计算答案的 1 分代码,实际运行时间最大为 500\text{ ms},空间最大为 100\text{ MB}。这意味着我们有 4.5\text{s} 的时间和 412\text{ MB} 空间的余量可以用来编码信息,这就是我们的通信带宽

我们为时间选定一个“步长” T(即相邻两个时间状态的时间间隔),内存同理,设其为 M。那么一次评测能为我们提供的信息量就是 \log_2\left(\dfrac{4500}{T} \times \dfrac{412}{M}\right) \text{ bit}

显然,T,M 不可能越小越好,因为评测机有系统波动噪声。对于洛谷评测机而言,取 T=\text{100 ms}M=\text{10 MB} 是比较精确且安全的。这样一发提交就能获得约 10.8\text{ bit} 的信息量,这代表了极高的数据传输效率。这意味着只需要两三发提交,就能完整套出这个测试点的答案。

拓展到多测试点怎么做呢?在此之前,我们需要区分当前正在评测的是哪个输入数据。于是我们就设计一个简单的哈希函数,对输入数据求哈希。按照上面的方法,你可以先套出输入数据的哈希集合。在洛谷这种测试点顺序固定的平台上,你直接就能知道每一个测试点对应的哈希值是什么(因为洛谷会直接显示 Testpoint #1, #2 ...)。

最后并行获取数据就行了,再多测试点也没有用。即使只拿到了哈希集合,攻击者也可以逐个击破:在代码里写个判断,如果当前输入数据的哈希值不是我们要攻击的那个,就直接 exit(0) 屏蔽掉;如果是,就执行侧信道泄露。照样能精准拿到每一个点的答案。

这导致攻击者在缺乏针对性审计的情况下,极易伪装成正常提交,从而绕过常规的自动化检测。

下面的代码就是一个参考,需要注意的是,为了避嫌,此代码中的部分数据已被删除,仅作原理验证参考。

:::warning[侧信道数据还原参考代码(请勿在实际比赛中模仿,否则将面临封号处罚)]

/* 前面的代码已省略,仅展示核心部分 */
long long LongestValidParentheses() {
    int N = GetN();
    int id = GetMyId();
    if (id != 0) return 0;
    long long M = GetM();
    if (N <= 0) return 0;
    long long L = M / N;
    long long start = id * L;
    long long end = (id + 1) * L - 1;
    int hsh = 0; // 输入哈希
    for (long long i = start; i <= end; ++i) {
        char c = GetCharAt(i);
        hsh = (hsh * 3 + (c == '(' ? 1 : 2)) % 1021; // 注:此代码针对上文提到的特定通信题,使用了该题的交互 API 获取数据特征。对于传统题,只需将 API 换成 scanf/cin 读入并做哈希即可,原理完全一致。
    }
    int answers[6][6] = {
        {94, 62, 68, 66, 63, 92},
        {264, 132, 78, 116, 93, 102},
        {414, 382, 78, 146, 193, 282},
        {184, 152, 168, 186, 323, 262},
        {184, 152, 158, 246, 373, 402},
        {-1, -1, -1 /* 已隐藏该数据段 */, 176, 93, 102}}; // 套出的答案数据(空间通道)
    id = -1;
    if (hsh == 3 + 32 * 25) id = 0;
    if (hsh == 29 + 32 * 15) id = 1;
    if (hsh == 16 + 32 * 11) id = 2;
    if (hsh == -1 /* 隐藏 */) id = 3;
    if (hsh == -1 /* 隐藏 */) id = 4;
    if (hsh == -1 /* 隐藏 */) id = 5; // 套出的输入哈希
    if (id == -1) while (1);
    int pans = 0;
    for (int i = 1; i <= 5; ++i) {
        pans += (((answers[i][id] - answers[0][id] - 10) / 10) << ((i - 1) * 5));
    }
    return pans; // 还原答案
}

:::

Link

虽然笔者在赛后复现时,由于需要进行参数微调、信道去噪,且为了简化论证仅使用了空间通道,导致共消耗了 23 次提交。但对于有备而来的攻击者而言,在本地充分测试后,最终实施攻击所需的提交次数完全可以压缩至理论的极低次数。

漏洞在哪里

这种非常容易受攻击的题目,其特点无非就是以下几点:

  1. 输入输出是固定的(显然大部分传统题都是如此)。
  2. 答案的信息熵极低,例如全是 Yes/No 判断,仅仅输出单个数字等。
  3. 输入的信息熵很低,或者输入或某些特征被知道后答案得出不难(即把题目降维打击成提交答案题,可能和上一条本质相同)。
  4. 存在一个弱于正解的做法,能在规定的时空限制内,强行算出正解才能算出的答案。 在上面的例子中,我们的 1 分做法就是可以得到精确答案的。
  5. 写不同档次做法的选手,从评测机得知的信息是完全一样的。

在上述特征中,第四、五点是核心前提。只要一份低效的暴力代码能在时空限制内勉强计算出正确答案,它就有能力将答案编码并通过时空通道泄露出去。

这也是为什么“最优解奖励”极易成为安全漏洞的原因:一份运行慢但能够 AC 的暴力代码,与一份真正的最优解代码,从评测机获得的反馈信息完全相同。攻击者可以利用暴力代码套出全部数据,再打表提交,伪装成最优解。

例如,笔者曾经命制的试题,前两条刚好就踩中,而相关比赛中,这个题目存在一个最优解奖励,这正好符合上述安全漏洞特征,在此作为典型案例进行分析与警示。

因此,在实际比赛中,如果要设置最优解奖励,必须考虑到这一点。

那如果说,你真的出了这种精准踩雷的题,又不想放弃这个 Idea,你会怎么去防备选手获取额外信息呢?

这里我们介绍两种派系的防御方式:一种是绝对防御,另一种是提升攻击成本的博弈防御(主要是对于现阶段的洛谷 OJ)。

绝对防御

探讨前提

对于这种防御,我们忽略“人防”、“赛后代码查重”等带有主观判定的手段。这里探讨的是如何用纯技术手段完全封死此路线。

这里,我们的目标是,能否从技术上防止一个不会正解的人获得正解的分数? 而“防止恶意争夺最优解”不在本节讨论范围内,因为既然能 AC,状态通道就已经全开了。

方案 1 - 子任务资源分离

如果我是这场比赛 H 题的出题人,我可能把题改成这个样子:

每一个测试点给出严格的通信次数限制 P,例如:

::cute-table{tuack} 子任务编号 特殊性质 依赖子任务 分值
0 是样例 0
1 P=37500500 0 1
2 P=1471 1 1
3 P=1180 2 1
4 P=1010 3 1
5 P=898 4 1
6 P=821 5 1
...
S P=425 S-1 4
S+1 P=424 S 4

这是一个极端但是最有效的解决方案。对于那些只会暴力的选手,他们将极难在 Subtask #2 及以上的测试点中获得任何关于答案的信息。确实,因为暴力程序在严格的资源限制下,尚未计算出正确答案便会被评测系统终止运行,从而在源头上阻断了信息的泄露。

当然,这种方案的缺点很显然:技术上可能不支持这么多子任务,只能取舍部分分;更痛苦的是,为了防止数据交叉泄露,弱子任务的点绝对不能出现在强子任务中。

方案 2 - 动态数据生成

较为直观的应对方案是在交互库中引入动态数据生成逻辑,替代传统的在本地生成数据原文后上传。这种方法不仅能压缩数据体积、增加强度,还能防住针对固定数据的打表作弊。在部分题目中,可能也已得到了应用(例如 Hash Killer III【Open Problem】,也这就是该题此前从未被侧信道攻击攻破的原因)。

但缺点是引入了随机性,如果题目轻微卡常,选手的体验会非常差:同一份代码,这发 95 分,下一发可能就 100 分了。

值得注意的是,有些出题人试图通过打乱测试点显示顺序来实现“动态化”。但正如前文所述,这种方法无法阻断泄露——攻击者只需提取输入的哈希特征,即可精准识别并屏蔽非目标测试点。打乱顺序只是限制了并行获取数据的效率,并未解决根本问题。

方案 3 - 状态抹除

洛谷 OJ 是支持自定义计分脚本的,这意味着可以直接在计分逻辑中抹除测试点的时空状态。之前的攻击依赖于时空信息,抹除它们就能切断通信通道。

例如,对于“子任务”类型的计分,我们可以写出类似下面的逻辑:

@flg = 1;
if @status1 != AC; then @flg = 0; fi
if @status2 != AC; then @flg = 0; fi
...
// flg 即表示该子任务是否通过
if @flg == 0; then
  @status1 = WA; @time1 = 0; @memory1 = 0; @score1 = 0; // 强制覆盖未通过的测试点信息
  @status2 = WA; @time2 = 0; @memory2 = 0; @score2 = 0; 
  ...
  @final_status = UNAC;
  @final_time = 0;
  @final_memory = 0;
else
  @final_status = AC;
  @total_score = 100;
  // 全部 AC 了,时空数据就无所谓了,因为选手已经具备了通过该子任务的能力
fi

缺点也非常明显:剥夺了非 AC 状态的时空反馈,正常选手的调试负担会大幅度增加,比赛的调试与提交体验会受到显著影响。

博弈防御

讨论前提

如果不想牺牲正常选手的体验,只想让攻击者的难度成倍上升,并允许赛后投入少量人力抓作弊,那么可以使用以下方式。

方案 1 - 评测结果聚合(屏蔽单点详情)

这种机制在 POJ 等老牌 OJ 上很常见:不给你看每个点的详情,只显示整个子任务的最大时间最大空间。这个在洛谷上用自定义计分脚本同样可以实现。

但这能彻底防住吗?很可惜,依然会被攻击。

攻击者在获取输入特征(哈希)时,可以利用类似“字典树(Trie)前缀匹配”的思想,结合二分法进行布尔盲注攻击。

具体来说,假设攻击者想找出当前子任务中字典序最大的哈希值,已知其前缀为 L。代码可以这么写:

一发提交测试一个 i。如果评测结果显示该子任务的“最大时间”是异常的,说明数据中存在前缀为 L+i 的哈希值。配合二分查找,很快就能锁定最大的哈希值,将其加入黑名单后,再去寻找次大值,以此类推。

不过,虽然聚合结果无法做到绝对防御,但它极大地增加了人为统计的繁琐程度和出错率,显著提升了攻击成本,提交量将会同时乘上测试点数目和字典树高度。如果真有人这么干,后台看他满屏的提交记录就能抓到了。

方案 2 - 状态模糊化

其实就是 绝对防御 - 方案 3 的妥协版本,把最终展示的时空做阶梯化处理,例如:

相比“绝对防御-方案3”的彻底抹除,该方案在保障正常选手基本调试需求的同时,大幅降低了侧信道的通信带宽,迫使攻击者的提交次数成倍上升。

方案 3 - 设计优化

如果以上方法都不满意,那大概率是题目本身的设计需要优化了。

这个只能根据实际情况操作。但通常情况下,最有效的改法就是开启多测。把几十组数据打包进同一个测试点里,替代传统 Subtask 捆绑测试。

这不仅极大地提高了答案的信息熵(从一个数变成几十个数的序列),还让单个点的时间空间被数十组数据均摊,使得细微的 sleep 难以被准确测量。在 HDUOJ 的比赛中,这个方案的应用十分常见。

或者,将 Yes / No 改为要求输出具体方案,将“对 10^9+7 取模”改为“对 10^{18}+31 取模”等,增加答案量级。

这肯定是所有增大攻击成本的方法中,最立竿见影的一个。

总结

写到这里,我们可以用几个核心的技术事实,来回应关于评测机侧信道攻击最常见的疑问,希望能为每一位出题人和平台开发者提供最务实的参考。

1 - 侧信道防护的实际意义

日常刷题确实没人会用。但安全防护遵循木桶原理,在涉及奖金、保研、升学或高规格排名的正式比赛中,只要漏洞客观存在,就一定会有人尝试。安全设计防范的是极少数恶意,而非约束自觉的多数人。

2 - 传统反作弊机制的局限性

传统的查重系统比对的是代码结构(AST 相似度),而侧信道是信息论层面的泄露,作弊者完全可以使用不受保护的小号跑暴力套取数据特征,拿到特征后,大号提交一份变量名、代码结构完全重构的代码。这两份代码在查重系统眼里毫无关联,传统的自动化查重对此很难有效拦截。

3 - 关于 System Test

赛时只测 Pretest、赛后全量重测确实是阻断赛时反馈通道的终极防御。但很可惜,在绝大多数的中小型比赛平台、校级 OJ、甚至许多主流 OJ(包括洛谷)来说,System Test 并不是完整支持的。在缺乏全量重测的场景下,低成本的博弈防御依然是出题人的刚需。

说到底,数据是死的,代码是活的。只要精细的反馈通道存在,降维打击就永远存在。出题不仅是考察算法,也是评测机制上的安全攻防。

希望每一个出题人在设计题目和测试数据或设置最优解奖励时,多留一个心眼。并希望以后的 OJ 编写者们在设计评测机制时,能够全面考虑这类通道的存在,避免因测试点设计或计分逻辑的单一性,给侧信道攻击留下可乘之机。

AI 写作说明:本文在初稿完成后,使用 Gemini 3.1 Pro Preview 对部分句式逻辑、排版格式及错别字进行了辅助润色。