Блог пользователя Diego

Автор Diego, 3 года назад, По-русски

TLDR

Если вы пишете на питоне, используете set или dict и хотите избежать взломов -- можете читать только последний раздел.

Введение

Большинство людей, интересующихся взломами знают про метод создания тестов, нацеленных против unordered контейнеров в C++. Подробнее этот метод описан в посте. Однако гораздо хуже освещено создание подобных тестов для set и dict в python, что я и решил исправить данным постом.

Немного теории о хеш-таблицах

Я не буду вдаваться в подробные описания и доказательства, поскольку их полно в открытых источниках. Разберем только то, что нас интересует в данном случае.

Хеш-таблица c открытой адресацией -- это структура данных, которая хранит набор элементов в массиве заведомо большего размера, определяя позицию очередного элемента, как его хеш, взятый по модулю размера массива. Если хеши нескольких элементов дают одну и ту же ячейку в массиве, то хеш-таблица пытается взять другую ячейку по некоторому правилу, которое зависит от конкретной реализации таблицы. Обычно это правило сводится к проверке ячеек $$$f(x), f(f(x)), f(f(f(x)))$$$ и так далее, пока не найдется пустая. В качестве функции $$$f$$$ часто берется линейное преобразование $$$(a * x + b) \% size$$$, где $$$size$$$ -- размер массива, а $$$a$$$ и $$$b$$$ взаимно просты с ним.

Python

В Python для реализации dict используется хеш-таблица с размером массива, равным степени двойки, а преобразование чуть сложнее простого линейного -- $$$f(x) = (5x + 1 + pertrub) \% size$$$, где $$$pertrub$$$ изначально равен хешу, но на каждом шаге уменьшается в 32 раза. Подробную реализацию можно посмотреть в репозитории.

Кроме того, хеш функция для чисел в питоне очень предсказуема, это просто само число.

Таким образом, для составления контртеста достаточно найти последовательность индексов, которые будут перебираться для конкретного числа и заранее занять их, а затем спровоцировать поиск этого числа в словаре, что довольно легко сделать для большинства задач.

Реализация set в Python3 чуть сложнее, там проверяется не одна ячейка, а 10 последовательных, что можно наблюдать тут. Однако построить тест в этом случае тоже не очень сложно, достаточно вместо одного числа $$$x$$$ добавлять $$$x, x + 1, x + 2, ..., x + 9$$$.

Проверка

Для проверки я использовал посылки 153032429 (Спасибо turkids за нее) и 153408991 c Codeforces Round 781 (Div. 2). Результат можно найти в виде взломов номер 796358 и 796362. Судя по результату "Неизвестный вердикт", все пошло слишком хорошо и я заодно сломал авторское решение. Подробнее могут знать shishyando и Kirill22.

Как защититься?

В отличие от C++, Python не предоставляет возможности определить свою хеш-функцию для существующего типа (или я о ней просто не знаю), однако никто не мешает определить свой тип c другой хеш-функцией:

from random import getrandbits

RANDOM = getrandbits(32)

class Wrapper(int):
    def __init__(self, x):
        int.__init__(x)
    def __hash__(self):
        return super(Wrapper, self).__hash__() ^ RANDOM

Пример использования такого типа можно найти в 153409562. К сожалению, пока авторское решение ломается на моих тестах я не смогу проверить устойчивость своего решения с классом Wrapper. Однако локально оно работает достаточно быстро.

  • Проголосовать: нравится
  • +178
  • Проголосовать: не нравится

»
3 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

I can't view the hacks

Image
»
3 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

This is very interesting. Did Python developers use SipHash for string keys in order to defend against Hash DoS, but left integer keys unprotected?

from Python's configure.ac
  • »
    »
    3 года назад, # ^ |
    Rev. 4   Проголосовать: нравится 0 Проголосовать: не нравится

    It's probably due to overhead on short keys. Rust, for example, uses SipHash-1-3 by default on all keys, however it does have a lot of overhead for short keys (like integer keys very common in competitive programming problems), e.g. compare:

    153429932 Siphash-1-3, 4694 ms

    150361213 custom hash function based on NASAM, 2167 ms

    • »
      »
      »
      3 года назад, # ^ |
        Проголосовать: нравится 0 Проголосовать: не нравится

      There's also aHash available for Rust: https://github.com/tkaitchuck/aHash

      And an unfinished discussion here about its implementation details when AES instructions are not available: https://github.com/tkaitchuck/aHash/issues/106

      • »
        »
        »
        »
        3 года назад, # ^ |
          Проголосовать: нравится 0 Проголосовать: не нравится

        Yeah I've noticed it

        But there's no way to pull its crate for CF, and when I tried implementing it for myself it's much slower than my custom hash, at least on CF; perhaps it's not configured correctly to do hardware-accelerated AES

        • »
          »
          »
          »
          »
          3 года назад, # ^ |
            Проголосовать: нравится 0 Проголосовать: не нравится

          This is interesting, because your write_u64 hasher function doesn't exactly look lightweight with 3 multiplications and a bunch of shifts/rotates. I suspect that the specialized code path for short keys in aHash fallback should do fewer calculations. But this needs to be confirmed. Also AES is another part of the puzzle. Codeforces hardware is modern enough to support AES.

          • »
            »
            »
            »
            »
            »
            3 года назад, # ^ |
            Rev. 3   Проголосовать: нравится 0 Проголосовать: не нравится

            To be clear, what I tried to implement was the AES version (using unsafe to call the AES intrinsic), not the fallback. My function is probably slightly slower than the AHash fallback, however I am somewhat less confident about the fallback's statistical properties

            Edit: Also, only two of those multiplies are actually performed. The update to the dither (self.1) gets optimized away when the hash function only calls write_u64 once per key.

»
3 года назад, # |
  Проголосовать: нравится +13 Проголосовать: не нравится

Also check this.

  • »
    »
    3 года назад, # ^ |
      Проголосовать: нравится +19 Проголосовать: не нравится

    Thank you, I haven't seen it before. Also I tested my method for PyPy and it work as well as with cpython. Submissions: 153445268 and 153445556.

    • »
      »
      »
      3 года назад, # ^ |
      Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

      Why is Wrapper(i)=i? I checked for a few integers and it gives the same value!! Can I use this method in dictionaries for two different data types?

      • »
        »
        »
        »
        3 года назад, # ^ |
        Rev. 2   Проголосовать: нравится +5 Проголосовать: не нравится

        Wrapper is inherited from int, so it can be used as regular integer (but type(Wrapper(...) + Wrapper(...)) == int!). I do not know what would happen if you mix Wrappers and ints in same dict, but it can be combined with other types, like str, tuple, etc.

        • »
          »
          »
          »
          »
          3 года назад, # ^ |
            Проголосовать: нравится 0 Проголосовать: не нравится

          Ohh. Now I get it. type(Wrapper(i)) is different. Thanks! What does int.__init__(x) mean in init function in Wrapper Class.

          • »
            »
            »
            »
            »
            »
            3 года назад, # ^ |
              Проголосовать: нравится +5 Проголосовать: не нравится

            Those two init lines don't do anything and should just be removed. They are nonsense.

»
3 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

а в Python нет аналога map или set из C++, которые работают за O(log)?

  • »
    »
    3 года назад, # ^ |
      Проголосовать: нравится +3 Проголосовать: не нравится

    Я могу предложить только написать какой-нибудь декарт или splay руками и добавить в шаблон. Но я не питонист, могу просто не знать о такой возможности.

    • »
      »
      »
      3 года назад, # ^ |
        Проголосовать: нравится 0 Проголосовать: не нравится

      Имхо, это плохая идея, я пытался написать декартач для этих целей, потратил кучу времени, в итоге $$$2e5$$$ вставок работают дольше секунды :(

»
3 года назад, # |
  Проголосовать: нравится +16 Проголосовать: не нравится

I can confirm this hash hack works in both PyPy2 and PyPy3. The hack is simple enough that any Python user should expect to be hacked using this.

As for the work around. I personally think a better thing to do is to use

from random
RANDOM = random.randrange(2**62)

def Wrapper(x):
  return x ^ RANDOM

This is arguably not as nice, but it should run a lot faster/use less memory in PyPy than using a custom class. Also wrapping int fails for big integers in PyPy2, I'm not entirely sure as to why big ints are a problem.

  • »
    »
    3 года назад, # ^ |
      Проголосовать: нравится 0 Проголосовать: не нравится

    Are non-int (str, tuple, ...) keyed dictionaries safe against this type of hack?

    If not what would an efficient protection be?

    • »
      »
      »
      3 года назад, # ^ |
        Проголосовать: нравится +3 Проголосовать: не нравится

      Hash of str is randomized, so it looks quite hard to make predictable collision. But I haven't yet researched how tuple hashes work, so they can be vulnerable.

»
3 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

Anyone who was here before me want to comment on how this went for unordered_map? Seems like a pretty short path from esoteric/specific hack that I might not even mind getting hit with individually to... thing that caused FSTs and complaints, and therefore a thing to be pre-empted by inclusion in pretests.

I dunno. I grok and mostly agree with notions like "it is the responsibility of the competitor to know what they're using, even if choices are implied/constrained, they're still algorithmic decisions and therefore fair game" hence the 'not even mad' sentiment in the individualized scenario above. I guess maybe that masochism breaks down at the point where it's no longer up to the would-be hacker to execute the hack, and/or it only takes one successful hack to get replicated across the entire contest...?

Think I answered my own question. Doooo we like it this way though? I don't know at what point something becomes a bad barrier to entry, feels subjectively like we tend to run towards that point wherever it is though...

  • »
    »
    3 года назад, # ^ |
      Проголосовать: нравится 0 Проголосовать: не нравится

    I have seen very few people hack unorederd_map, including me.

    In the last round, tests against unordered_map were included in the main testset and I belive it is well, because it gives beginners a lesson that they should not blindly rely on the tools of the language, but should be aware of how these tools work. Better that they find out about it in the form of a dropped task than in the form of a DoS attack on the service they will one day create.

    And if the creators of the round did not provide some types of tests, it can always be done by participants in the form of hacks.

»
3 года назад, # |
Rev. 4   Проголосовать: нравится 0 Проголосовать: не нравится

Well, I would suggest this approach, but it slows down the solution, which is already a huge problem for python.

import random

RANDOM = random.randrange((1 << 31) - 1)

class Dict(dict):
    def __setitem__(self, __k, __v):
        return super().__setitem__(__k ^ RANDOM, __v)

    def __delitem__(self, __v):
        return super().__delitem__(__v ^ RANDOM)

    def __getitem__(self, __k):
        return super().__getitem__(__k ^ RANDOM)

    def __contains__(self, __o: object) -> bool:
        return super().__contains__(__o ^ RANDOM)
»
3 года назад, # |
Rev. 2   Проголосовать: нравится +15 Проголосовать: не нравится

Nice hack! Actually this hack can be solved by converting the number into string. Here is my new solution, only 20% slower than my original solution. 153489121

I suggest every python user to look at this new post, otherwise your code may be hacked in the future since dictionary is used so frequently in the contest.

»
3 года назад, # |
  Проголосовать: нравится +7 Проголосовать: не нравится

I opened up an issue about this over at PyPy. link

»
2 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

It happened again in a div 4 problem H (Gambling)

»
2 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

can we just change int to string while storing it as key in dictionary... ? this can fix this error..

»
2 года назад, # |
  Проголосовать: нравится 0 Проголосовать: не нравится

How does the "solution" help, since it keeps the collisions?

hash(a) = hash(b) is the same as hash(a)^RANDOM=hash(b)^RANDOM..

  • »
    »
    2 года назад, # ^ |
      Проголосовать: нравится 0 Проголосовать: не нравится

    This solution is not prevent collisions itself, it prevent collision-chains like hash(x) = hash(a_1), f(hash(x)) = hash(a_2), f(f(hash(x))) = hash(a_3), .... Xor with RANDOM changes every element and broke the chain.