Codeforces比赛中的unordered_map Hacks
打了一下Codeforces Round 891 (Div. 3),赛后看到F题的赛时通过解答被叉(hack)了一大把,非常好奇为啥,所以笔者在hack phase结束之后看了一下这些hack数据的hack点是什么,发现大多数都是叉unordered_map
的。这又让我想起刚开始打cf的时候在某场Div. 4中使用了unordered_map
结果在hack phase被叉的惨痛经历,然后我突然就想以牙还牙,以后也要叉别人的unordered_map
,于是就有了这篇文章。
unordered_map
和 map
的工作原理
在Codeforces比赛中,我们通常会选择map
而不是unordered_map
,尽管map
上的插入和查找操作的时间复杂度是$O(log\ N)$的,而unordered_map
的插入和查找操作却是均摊$O(1)$的。为什么我们要做这种令人费解的时间复杂度牺牲?原因就在于哈希表的内部工作原理使得它可能被轻易地攻击。
map
1 | std::map is a sorted associative container that contains key-value pairs with unique keys. |
map
内的键是有序的,并且使用模板中给出的Compare
函数排序。而且map
通常会使用红黑树实现,因此可以保持$O(log\ N)$的时间复杂度,比较稳定。
unordered_map
unordered_map
是哈希表,它通过给定的哈希函数,将键的hash值(整数)计算出来,并且这个hash值是均匀地分布在$[0,+\infty)$区间内的。这样元素的hash值能较少地发生冲突。但是我们的空间不是无穷大的,而且hash值难免会有冲突,因此我们通常会开辟一块有一定大小的空间,其中的每一个位置都作为一个bucket(桶),所有键的hash值都对桶的个数取模,然后把键值对作为一个节点放进对应的桶里面。因此,一个桶里面可能会有几个不同的键。插入或查找元素时,都会根据键计算桶的位置,然后尝试找到这个桶里面确实与当前键相同的一个节点。在数据十分随机,hash函数优秀的情况下,我们几乎不会遇到冲突(一个桶里面的节点很少),操作的均摊复杂度为$O(1)$
对unordered_map
的攻击分析
unordered_map
的均摊复杂度是在数据足够随机的情况下得出的,而在Codeforces比赛的hack phase中攻击者往往不会进行随机的数据生成,而是根据提交者的代码有意制造hack testcase,从而达到攻击的目的。而不幸的是,你使用的unordered_map
很有可能就是一个hacker的突破口。它可能会使得你的程序在hack phase中verdict变成tl
(time limit)。
攻击原理
unordered_map
中的bucket list并不总是初始长度,而是随着它内部存储的键值对数量逐渐扩大。在rehash
阶段,哈希表的桶数量可能会发生改变,然后把原有的元素放到新的这组桶里面去,如果我们能够构造一组数据,使得unordered_map
在某次resize后,很多(甚至全部)元素都挤到同一个桶里面去,那么unordered_map
的操作复杂度就会大幅度上升,甚至达到单次操作$O(N)$的水平,这样你原本认为是$O(N)$的程序就变成了$O(N^2)$水平!在$2e5$的数据下,这下不得不拿下tl
的verdict了。
因此,攻击者在知晓你的hash函数和哈希表实现的情况下,可以刻意制造许多hash冲突,让哈希表内部的某个或某几个bucket爆满,达到攻击的目的。
对GCC中unordered_map
实现的分析
打开unordered_map.h,查看class unordered_map
的内容,可以发现它内部使用一个名为_M_h
的_Hashtable
类型变量完成大部分操作,注意到还有一句typedef __umap_hashtable<_Key, _Tp, _Hash, _Pred, _Alloc> _Hashtable;
,我们继续追踪__umap_hashtable
的定义如下:
1 | template<typename _Key, |
__detail
这个namespace下有一些关于哈希表的细节实现,我们注意到,__detail::_Mod_range_hashing
和__detail::_Prime_rehash_policy
很重要,很有可能是关于内部如何计算bucket index和控制rehash的。追踪到hashtable_policy.h
__detail::_Mod_range_hashing
1 | /// Default range hashing function: use division to fold a large number |
这是默认的hash函数,把定义unordered_map
时提供的用户hash函数的较大的结果折叠到$[0, N)$的范围,也就是桶索引的区间。
__detail::_Prime_rehash_policy
1 | /// Default value for rehash policy. Bucket size is (usually) the |
它控制Rehash策略,桶个数通常时使得负载因子足够小的最小的质数。负载因子其实就是当前键值对的数量除以桶的个数,这个struct在初始化时就提供了一个最大负载因子的参数,且默认为1.0f
。负载因子足够小,也就是保证当前负载因子小于设定的最大负载因子。同时,这个文件中也包含了_M_bkt_for_elements
函数的实现,就是键值对个数除以最大负载因子。还有一个很重要的参数:
1 | static const std::size_t _S_growth_factor = 2; |
大小增长因子,resize之后的大小至少是之前的两倍。
这个结构体中有两个函数_M_need_rehash
和_M_next_bkt
其实现位于hashtable_c++0x.cc。_M_next_bkt
中返回的总是质数,并且有一个质数表__prime_list
,其具体内容位于hashtable_aux.h。
回到_Hashtable
和std::hash
在_Hashtable
中查找几个函数被调用的位置,结合以上几个函数的具体实现,在最大负载因子为默认的1.0
时,该unordered_map
的实现总是将桶大小设为某个质数,并且是__prime_list
中的某个质数。而std::hash
中预先为几种整数类型实现的哈希函数就是返回identity,加上前面__detail::_Mod_range_hashing
的取模运算放入bucket中。我们可以测试__prime_list
中的质数,不断使用常数$C$加上该质数$P$倍数的键$C+i*P$插入到unordered_map
中。如果unordered_map
能够在某次rehash中将桶大小设为这个质数,我们的攻击就会成功(当然,这个质数应该较大,否则它很快就会被另一个质数代替了,也不能使高复杂度的操作持续多久)。
攻击开始
筛选的部分质数
我们针对$2e5$的数据,挑选了部分说不定能被攻击成功的质数:
1 | long long nums[] = { |
利用Custom Invocation测试
Codeforces上有Custom Invocation
功能,可以不向任何题目提交但在评测机上执行自己代码的测试,而且运行时限高达15秒,我们写一段模拟哈希冲突攻击的代码,对GCC中的unordered_map
进行测试。但是上面的质数太多了,我们不想分多次测试,需要利用上面的_S_growth_factor=2
的性质,先找到小的,再推算较大的。因为下一个必然是第一个大于前一个卡点的两倍的质数。
我们利用如下代码跑一些不至于超出15s时限的小型测试:
1 |
|
由于测试数据较多,我们只选取在某个编译器版本出现了异常的测试结果如下:
编译器 | 6427 | 7517 | 10273 | 12983 | 15173 | 20753 |
---|---|---|---|---|---|---|
GNU G++14 6.4.0 | 0.015s | 0.092s | 0.016s | 0.015s | 0.268s | 0.015s |
GNU G++17 7.3.0 | 0.046s | 0.015s | 0.015s | 0.173s | 0.015s | 0.016s |
GNU G++17 9.2.0 (64bit, msys2) | 0.020s | 0.183s | 0.032s | 0.016s | 0.633s | 0.015s |
GNU G++20 11.2.0 (64bit, winlibs) | 0.015s | 0.015s | 0.313s | 0.031s | 0.031s | 1.205s |
根据_S_growth_factor
,从__prime_list
取得的较大的可攻击素数以及攻击结果如下:
编译器 | 素数 | 运行状态 |
---|---|---|
GNU G++14 6.4.0 | 126271 | 3.217s |
GNU G++17 7.3.0 | 107897 | 3.387s |
GNU G++17 9.2.0 (64bit, msys2) | 126271 | tl |
GNU G++20 11.2.0 (64bit, winlibs) | 172933 | tl |
其中运行状态为tl
的表示运行时间超过15s,Codeforces已停止继续执行该代码。
对非64bit的情况分析
两个标明是64bit的编译器都得到了tl
的运行结果,为什么另外两个编译器产生的代码却没有时间超限?
使用Custom Invocation
运行cout << sizeof(std::size_t);
,发现未标明64bit的编译器均为32bit。而我们hash函数返回的正是std::size_t
。因此在hash时,我们的部分数值因为超出了32bit整数可表示的范围,内置hash函数产生的结果并不是我们选择的质数的倍数,导致这些数值被分进了其他的bucket中,我们增加部分判断:
1 | uint32_t performHash(il p) { |
修改insertNumbers
的代码,对溢出的值做一点小操作:
1 | bool x86 = sizeof(std::size_t) == 4; |
结果在两个32bit编译器上也发生了tl
运行结果。
攻击小结
利用GCC中的unordered_map
特性,我们可以较容易地针对编译器版本选择素数进行攻击。
上面的运行时间其实95%以上都是插入键值对的时间,因为使用迭代器遍历哈希表中的键值对的复杂度总是$O(n)$的,而实际攻击中构造的特定查询会更加耗时,在多数题目时限小于6s的情况下我们可以通过这种手段攻击成功。此外还需要注意待攻击的代码中是否修改了max_load_factor
配置,这可能导致我们选择用于攻击的素数发生变化。如果是32bit编译器,还要注意hash时可能发生的溢出,修正我们生成的数据。
另一种攻击法
unordered_map
提供了bucket_count
方法,我们可以在hack generator中根据这个数值不断构造输入数据,只要我们generator选择的编译器和待攻击的代码的编译器相同,就可以达到攻击效果。例如这份Hack Generator
使我们的哈希表变安全
此处的详细信息请参考neal在Codeforces上的博文。使用基于评测时间的随机数和splitmix64算法作为我们的自定义哈希函数,避免被攻击:
1 | struct custom_hash { |
然后,将custom_hash
添加到我们的unordered_map
:
1 | unordered_map<long long, int, custom_hash> safe_map; |
就可以防御住较多的攻击。