Как-то раз мне понадобилось несколько наборов строк с коллизией по хеш-коду. То есть таких, чтобы значение String::hashCode() совпадало для всех строк в наборе.

Блуждание по интернету не дало результатов, примеров было мало и все они довольно однообразны. Поиск по словарям подарил забавную пару "javascript's".hashCode() == "monocle".hashCode(), но практической пользы не принёс. Полный перебор не рассматривался в виду скорой тепловой смерти вселенной.

Тот самый случай, когда проще сделать всё самому. Стандартная хеш-функция строки в Java считается криптографически нестойкой, так что знаний из школьного курса математики должно быть достаточно.


Коллизии


Для начала взглянем на String::hashCode() из Java 8:

String::hashCode()
/**
 * Returns a hash code for this string. The hash code for a
 * {@code String} object is computed as
 * <blockquote><pre>
 * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
 * </pre></blockquote>
 * using {@code int} arithmetic, where {@code s[i]} is the
 * <i>i</i>th character of the string, {@code n} is the length of
 * the string, and {@code ^} indicates exponentiation.
 * (The hash value of the empty string is zero.)
 *
 * @return  a hash code value for this object.
 */
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}



Хеш-код непустой строки вычисляется по формуле:



Взятие остатка от деления случается при переполнении разрядной сетки типа int, остальная часть формулы очевидна из кода и описания.

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

При замене начала строки требование на совпадение длин исчезает, так как больше нет символов, на которые такое несовпадение может повлиять.




Простейший алгоритм генерации коллизий очевиден:
  1. Берём произвольные два подряд идущих символа в строке, Ci и Ci + 1
  2. Код символа Ci увеличиваем на целое положительное число N
  3. Компенсируем увеличение первого символа уменьшением кода символа Ci + 1 на 31 * N.
  4. Повторяем это действие с произвольными парами символов в строке до тех пор, пока нам не надоест.


Главное ограничение: коды изменённых символов должны оставаться в диапазоне значений типа char, от 0 (\u0000) до 65_535 (\uFFFF) включительно.

Именно этот приём чаще всего используется в примерах из интернета.

https://stackoverflow.com/a/1465719:
	"0-42L"
	"0-43-"


https://stackoverflow.com/a/12926279:
	"AaAa"
	"BBBB"
	"AaBB"
	"BBAa"


Если же пара строк с идентичными хеш-кодами нужна нам здесь и сейчас, а калькулятора под рукой нет, то добавив в начало любой строки префикс "Хабрахабр -- торт! ЧЫМДШРС " (без кавычек) мы с лёгкостью получим такую пару в своё распоряжение.

Прообразы


Это было совершенно не трудно. Пойдём дальше и попробуем найти первый прообраз, то есть сгенерировать строку, имеющую нужный нам хеш-код.

Алгоритм расчёта хеш-кода строки очень похож на перевод 31-ричного числа из строкового представления в числовое, воспользуемся этим. Нашими «цифрами» будут 31 первых символа Unicode, от \u0000 до \u001E, а алгоритм — совершенно стандартным.

В рамках данной статьи будем рассматривать хеш-код как 32-битное беззнаковое целое, представление в дополнительном коде позволяет нам такую вольность.

private static String findFirstPreimage(int hash) {
    StringBuilder builder = new StringBuilder();

    for(long current = Integer.toUnsignedLong(hash); current != 0; current /= 31L) {
        builder.insert(0, (char) (current % 31L));
    }

    String result = builder.toString();
    assert hash == result.hashCode();
    return result;
}


Как нетрудно убедиться, этот подход действительно работает и выдаёт строки с нужными нам параметрами. Но есть у него и серьёзный недостаток: все символы в сгенерированных строках — непечатные. IntelliJ IDEA, к примеру, показывает такие строки и в отладчике, и во встроенной консоли неотличимыми от состоящих из одних пробелов.



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

Впрочем, ничто не мешает нам использовать любые другие идущие подряд символы. Диапазон БЯ отлично подойдёт на эту роль. Хеш-коды генерируемых строк при такой подмене по-прежнему будут монотонно возрастать с шагом в единицу, но появится и несколько особенностей.

Хеш-код сгенерированной строки будет иметь смещение, напрямую зависящее от её длины и первого символа используемого диапазона, он выступает в роли нуля. К примеру, для строки длиной в три символа и буквы Б в качестве первого символа алфавита оно будет равно Integer.toUnsignedLong("БББ".hashCode()).

Это смещение необходимо учитывать и делать на него поправку. Причём, для каждой пары ( <первый символ диапазона>, <длина> ) эта поправка будет своей.

Все наши вычисления проходят в кольце вычетов по модулю 232, а потому узнать величину поправки элементарно. Достаточно взять строку из N «нулей» и вычесть хеш-код этой строки из значения 232. Результатом будет число, на которое нужно увеличить значение хеш-кода для генерации правильной последовательности символов.

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

class PreimageGenerator { ... }
import java.util.Arrays;
import java.util.Iterator;
import java.util.NoSuchElementException;

/**
 *
 * String hash preimage generator.
 *
 * @author Maccimo
 *
 */
public class PreimageGenerator implements Iterator<String> {

    private static final long MODULO = (1L << 32);
    private static final double LOG_31 = Math.log(31);
    private static final char MAX_ALLOWED_CHAR = Character.MAX_VALUE - 30;

    private final char alphabetStart;
    private final long[] hashCorrectionTable;

    private long nextHash;

    /**
     * Construct string hash preimage generator.
     * This constructor accept ordinary string hash code, as if returned from {@link String#hashCode()}.
     *
     * @param hash          string hash code
     * @param alphabetStart first of 31 consequent symbols, used to generate strings
     *
     * @throws IllegalArgumentException thrown if {@code alphabetStart} value is too large
     */
    public PreimageGenerator(int hash, char alphabetStart) {
        this(Integer.toUnsignedLong(hash), alphabetStart);
    }

    /**
     * Construct string hash preimage generator.
     * This constructor accept unsigned long hash code, can be calculated from ordinary hash code by invoking
     * {@link Integer#toUnsignedLong(int)}.
     *
     * @param hash          string hash code
     * @param alphabetStart first of 31 consequent symbols, used to generate strings
     *
     * @throws IllegalArgumentException thrown if {@code alphabetStart} value is too large or {@code hash} value is negative
     */
    public PreimageGenerator(long hash, char alphabetStart) {
        if (alphabetStart > MAX_ALLOWED_CHAR) {
            throw new IllegalArgumentException(
                String.format(
                    "Wrong alphabetStart value U+%04X. We need at least 31 symbol to work properly, only %d available.",
                    (int) alphabetStart, Character.MAX_VALUE - alphabetStart + 1
                )
            );
        }
        if (hash < 0) {
            throw new IllegalArgumentException(
                String.format(
                    "Wrong has value %,d. Must be non-negative.",
                    hash
                )
            );
        }
        this.nextHash = hash;
        this.alphabetStart = alphabetStart;
        this.hashCorrectionTable = precomputeHashCorrection(alphabetStart);
    }

    /**
     * Returns {@code true} if preimage generator has more elements.
     * (In other words, returns {@code true} if {@link #next} would
     * return an element rather than throwing an exception.)
     *
     * @return {@code true} if preimage generator has more elements
     */
    @Override
    public boolean hasNext() {
        return nextHash >= 0;
    }

    /**
     * Returns the next colliding string for given hash code.
     *
     * @return the next colliding string
     * @throws NoSuchElementException if the iteration has no more elements
     */
    @Override
    public String next() {
        if (nextHash < 0) {
            throw new NoSuchElementException();
        }

        int length;
        int correctedLength = 0;
        long hashCorrection;
        do {
            length = correctedLength;
            hashCorrection = hashCorrectionTable[length];
            correctedLength = calculateLength(nextHash + hashCorrection);
        } while (correctedLength > length);

        String result = generate(nextHash + hashCorrection, length);

        nextHash = nextHash + MODULO;

        return result;
    }

    private String generate(long hash, int length) {
        assert hash >= 0 : hash;
        char[] buffer = new char[length];
        Arrays.fill(buffer, alphabetStart);

        int i = length - 1;
        for(long current = hash; current != 0; current /= 31L) {
            buffer[i--] =  (char) (alphabetStart + current % 31L);
        }

        return String.valueOf(buffer);
    }

    private static long[] precomputeHashCorrection(char alphabetStart) {
        int maxLength = calculateLength(Long.MAX_VALUE);
        long[] result = new long[maxLength + 1];
        int correction = 0;
        for (int i = 0; i < result.length; i++) {
            result[i] = (MODULO - Integer.toUnsignedLong(correction)) % MODULO;
            correction = correction * 31 + alphabetStart;
        }
        return result;
    }

    private static int calculateLength(long number) {
        return number == 0 ? 0 : 1 + (int) log31(number);
    }

    private static double log31(long number) {
        return (Math.log(number) / LOG_31);
    }
}



Простейшим примером использования будет код, генерирующий 16 строк с таким же хеш-кодом, что и у строки "Java".

public class PreimageGeneratorDemo {

    private static final int COUNT = 16;
    private static final char ALPHABET_START = 'Б';
    private static final String ORIGINAL_STRING = "Java";

    public static void main(String... args) {
        PreimageGenerator generator = new PreimageGenerator(
            ORIGINAL_STRING.hashCode(), ALPHABET_START
        );

        boolean allHashesValid = true;
        for (int i = 0; i < COUNT; i++) {
            String preimage = generator.next();
            allHashesValid &= (ORIGINAL_STRING.hashCode() == preimage.hashCode());
            System.out.printf("\t\"%s\"%n", preimage);
        }

        System.out.println();
        if (allHashesValid) {
            System.out.println("All generated strings are valid.");
        } else {
            System.out.println("Some of generated strings are invalid!");
        }

        System.out.println();
        System.out.println("Done.");
    }
}


Хеш-код, для которого нам нужно найти первый прообраз и начальный символ используемого алфавита передаются параметрами в конструктор класса PreimageGenerator. Каждый вызов метода next() будет возвращать всё новые и новые значение первого прообраза для заданного хеш-кода.

Так будет длиться до тех пор, пока метод hasNext() не вернёт значение false. Внутреннее состояние генератора представлено типом long, что даёт нам примерно два миллиарда различных значений.

Two billion strings ought to be enough for anybody.

Десерт


На десерт нам досталась фраза "Хабрахабр -- торт! " (без кавычек). Мы хотим научиться вставлять её в начало любой строки и в результате получать строку с тем же хеш-кодом, что был у оригинала. Как мы уже выяснили в первой главе, для такого фокуса хеш-код вставляемой строки должен быть равен нулю.

У нашей фразы он отличен от нуля и значит необходимо вычислить строку, добавление которой изменит его в нужную сторону.

Для этого решим уравнение



Вспомним формулу из первой главы и преобразуем уравнение в эквивалентное



Значение P.hashCode нам уже известно, это хеш-код нашей фразы, интерпретированный как 32-битное беззнаковое целое. Подставив его в формулу мы сможем найти нужный нам хеш-код суффикса. Генерировать подходящую строку по известному хеш-коду мы научились во второй главе.

Для фразы "Хабрахабр -- торт! " он равен -1_287_461_747 или 3_007_505_549L, если рассматривать его как беззнаковое целое. Для упрощения расчётов зафиксируем длину суффикса 7 символами.



Полученную константу 3_196_431_661L используем для инициализации PreimageGenerator и получаем набор подходящих суффиксов.



Они же, в виде текста
    "Хабрахабр -- торт! ИГУБЧПЮ"
    "Хабрахабр -- торт! МЭУХЦЙГ"
    "Хабрахабр -- торт! СШФКХВЗ"
    "Хабрахабр -- торт! ЦУФЮУЪЛ"
    "Хабрахабр -- торт! ЫОХУТУП"



Остался последний штрих: наша строка-префикс неэстетично сливается с остальным текстом, обособим её пробелом в конце.

Для этого в правой части уравнения вместо нуля должно быть значение хеш-кода строки до того, как финальный пробел обнулит его. Чтобы получить нужное значение вычтем из нуля код символа пробела, он равен 32, и разделим полученное значение на 31.

Так как нам нужно целое неотрицательное решение, то вычитание 32 из нуля мы заменим на сложение с его противоположным числом, равным 232 — 32, а деление полученной разницы на 31 — умножением на обратное ему число3_186_588_639L.



Константу 9_843_021L всё так же используем для инициализации PreimageGenerator и смотрим что получится.



Они же, в виде текста
    "Хабрахабр -- торт! ДРЙСЭНБ "
    "Хабрахабр -- торт! ЙЛКЖЬЖЕ "
    "Хабрахабр -- торт! ОЖКЪЪЮЙ "
    "Хабрахабр -- торт! УБЛПЩЧН "
    "Хабрахабр -- торт! ЧЫМДШРС "
    "Хабрахабр -- торт! ЬЦМШЧЙХ "



Заключение


Задача выполнена полностью и теперь в нашем распоряжении бесконечное количество строк с совпадающими хеш-кодами.

Комментарии (17)


  1. freedbrt
    14.08.2022 00:49
    +1

    Возник вопрос, почему хеш функция строки именно такая? Откуда взялся коэффициент 31, и почему решили считать именно так?


    1. nin-jin
      14.08.2022 09:10
      +8

      31 - красивое простое число состоящее из 4 единичек в бинарном представлении. А учитывая, что ASCII символы занимают 7 бит, то получается, что примерно половина бит очередного символа тратится на "перемешивание", а половина на "рост" хеша.

      Не самый оптимальный коэффициент, конечно. Хеш растёт слишком медленно. Так что ведущие нули уходят лишь символу к седьмому.

      По всей видимости сначала реализовали самый простой алгоритм, а потом оставили его из соображений совместимости.


      1. Maccimo Автор
        15.08.2022 19:45

        В Java тип char с самого начала был 16-битным, а алгоритм хеширования позаимствовали из Kernighan and Ritchie's «The C Programming Language, 2 Ed.»


        В другом комментарии я дал ссылку на issue с объясненияи автора реализации.


    1. Maccimo Автор
      15.08.2022 11:00

      До Java 1.2 хеш-функция строки была другой:


      public int hashCode() {
          int h = 0;
          int off = offset;
          char val[] = value;
          int len = count;
      
          if (len < 16) {
              for (int i = len ; i > 0; i--) {
                  h = (h * 37) + val[off++];
              }
          } else {
              // only sample some characters
              int skip = len / 8;
              for (int i = len ; i > 0; i -= skip, off += skip) {
                  h = (h * 39) + val[off];
              }
          }
          return h;
      }

      Она была медленнее, коллизии для неё подобрать было ещё легче, для строк длиннее 15 символов учитывался лишь каждый S.length() / 8 символ, да ещё и в Java Language Specification была ошибка.


      Потом это обнаружили и Джош Блох предложил всё переделать, детали в JDK-4045622.


      Откуда взялся коэффициент 31, и почему решили считать именно так?

      Приведу несколько цитат из вышеупомянутого issue:


      The class of hash function that I recommend using is polynomials whose
      coefficients are the characters in the string:

      P(x) = s[0] * x^(n-1) + s[1] * x^(n-2) +… + s[n-1]

      The value of x is part of the definition of the hash function; choosing
      this value is somewhat of a black art.

      While this class of hash function is recommended in The Dragon Book
      (P(65599)) and Kernighan and Ritchie's «The C Programming Language, 2 Ed.»
      (P(31)), it is not attributed in either of these books.

      I went so far as to call up Dennis Ritchie, who said that he did not know where the hash function came from. He walked across the hall and asked Brian Kernighan, who also had no recollection.

      I'd probably select P(31), as it's the cheapest to calculate on a RISC machine (because 31 is the difference of two powers of two).

      Откуда взялось 31 не помнят даже Керниган и Ричи. Чёрная магия, как она есть.
      Если тема интересна, то стоит прочитать все комментарии к JDK-4045622 по ссылке выше.


    1. tsypanov
      15.08.2022 14:54

      Я бы задал другой вопрос: почему вообще Object.hashCode() возвращает int, а не long


      1. Maccimo Автор
        15.08.2022 17:01
        +1

        Эдак и до вопроса «почему локальная переменная типа long занимает 2 слота памяти, а ссылка на объект даже на 64-битной системе с -XX:-UseCompressedOops — один» дойти можно.


        1. tsypanov
          15.08.2022 21:48

          Это-то как раз понятно, детали реализации. А вот 32 бита на хеш-код больше похоже на один из жёстких косяков разработчиков языка из серии методов wait()/notify()/notifyAll() у всех объектов.


          1. nin-jin
            16.08.2022 00:33

            Вы не забывайте, что java запускалась даже на тапках, где 64 бита большая роскошь.


            1. tsypanov
              16.08.2022 10:33

              Так а что от этого меняется? 64-битный лонг останется 64-битным, а размер ссылки останется подробностями реализации, разве нет?


              1. nin-jin
                16.08.2022 10:39

                Скорость хеширования, разумеется.


                1. tsypanov
                  16.08.2022 11:26

                  Не очень понял, почему она должна меняться? Исходный набор обрабатываемых данных остаётся тем же, в данном случае это char[] / byte[]его размер тот же безотносительно того, считаем ли мы long или int.


                  1. nin-jin
                    16.08.2022 11:46

                    Считать в двойных машинных словах дороже, чем в одинарных.


                  1. Maccimo Автор
                    16.08.2022 16:54

                    Если на целевой платформе нет инструкций работы с 32-битными целыми, то их придётся эмулировать серией других инструкций, а это будет медленнее.


  1. hacker2001
    14.08.2022 03:16
    +9

    jshell почему-то воспринимается как js hell


  1. yerbabuena
    14.08.2022 20:40

    Порадуюсь за C# - там такой херни нет:

    https://dotnetfiddle.net/tAKk2N


    1. ptyrss
      14.08.2022 20:41

      то что не подошли именно эти символы не значит что там такой фигни нет.


    1. Maccimo Автор
      15.08.2022 07:26
      +2

      Эти строки и не могли подойти, в .net используется другой алгоритм хеширования. Плюс, насколько я вижу из исходного кода, предпринимаются попытки рандомизации.

      Но так как хеширование в общем случае это отображение бОльшего множества на меньшее, то коллизии по хеш-коду между различными строками будут и в .net.