Давайте абьюзить баг в java.lang.String, который позволит делать очень странные строки. Мы сделаем "Hello World", который не начинается с "Hello" и покажем, что не все пустые строки равны между собой. Научимся прожаривать строки в чужих классах.

Введение. Эквививалентность строк.

Прежде, чем мы начнем, давайте взглянем, как две строки из JDK оказываются равны между собой.

Почему "foo".equals("fox") == false?

Потому, что строки сравниваются символ за символом, и третий символ здесь различается.

Почему "foo".equals("foo") == true?

Может показаться, что в этом случае строки тоже сравниваются побуквенно. Но строковые литералы интернируются. Когда ты пишешь одинаковые строки в тексте одной и той же программы, о них уже нельзя думать как о дополнительных экземплярах с тем же самым содержимым. Это экземпляр в точности одной и той же строки. Первое, что делает String.equals — проверку вида if (this == anObject) { return true; }. Оно даже не смотрит на содержимое!

Почему "foo!".equals("foo⁉") == false?

Начиная с JDK 9 (JEP 254: Compact Strings), String внутри использует массив байтов. "foo!" содержит только простые символы, с кодом менее 256. Поэтому, класс String под капотом кодирует такие значения с использованием кодировки latin-1, используя по байту на каждый символ. "foo⁉" содержит специальный символ (⁉), которого нет в кодировке latin-1, поэтому он кодирует всю строку в в UTF-16, по два байта на символ. Поле String.coder следит, какая из двух кодировок используется в каждом конкретном случае. Сравнивая две строки с разными значениями coder, метод String.equals всегда возвращает false. Он тоже не смотрит на содержимое строк. Если одна строка может быть преобразована в latin-1, а вторая - не может, очевидно, они не могут быть одинаковыми. Или могут?

Заметка: compact strings можно выключить, но они включены по-умолчанию. В этой статье подразумевается, что эта фича включена.

Создаем сломанную строку

Как создаются строки? Как класс java.lang.String выбирает, использовать ли latin-1?

Строки можно создавать множеством способов, но мы здесь посмотрим на конструктор класса String, который принимает в качестве аргумента char[]. Вначале, он пытается закодировать символы в latin-1 через StringUTF16.compress. Если не получается, тогда возвращается null и конструктор откатывается к использованию UTF-16. Взглянем на упрощённую версию того, как это реализовано. В целях улучшения читабельности, здесь убраны все бесполезные переходы, проверки и аргументы из настоящей реализации, которая расположена здесь и здесь.

/**
 * Allocates a new {@code String} so that it represents the sequence of
 * characters currently contained in the character array argument. The
 * contents of the character array are copied; subsequent modification of
 * the character array does not affect the newly created string.
 */
public String(char value[]) {
  byte[] val = StringUTF16.compress(value);
  if (val != null) {
    this.value = val;
    this.coder = LATIN1;
    return;
  }
  this.coder = UTF16;
  this.value = StringUTF16.toBytes(value);
}

В этом коде есть баг. Он не всегда следует предположениям, на которых построен String.equals, и которые мы обсуждали выше. Вглядитесь в эт от код.

Джавадок говорит, что "последующие модификации массива символов не влияют на новую созданную строку". Но как насчет одновременных (конкурентных) модификаций? В этом конструкторе String есть гонки (race condition). Содержимое поля value может измениться во время как мы бросили попытку кодировать в latin-1, и перешли на UTF-16. В этом случае мы получаем строку, которая содержит символы в latin-1, закодированные как UTF-16. Эти гонки можно активировать следующим кодом:


/**
 * Берет строку в latin-1, и создает копию,
 * некорректно перекодированную в UTF-16.
 */
static String breakIt(String original) {
  if (original.chars().max().orElseThrow() > 256) {
    throw new IllegalArgumentException(
        "Can only break latin-1 Strings");
  }
  char[] chars = original.toCharArray();
  // В отдельном потоке, будем менять первый символ туда-сюда,
  // между чем-то, представимым в latin-1, и не представимым в ней.
  Thread thread = new Thread(() -> {
    while (!Thread.interrupted()) {
      chars[0] ^= 256;
    }
  });
  thread.start();
  // В то же самое время, будем звать конструктор строки,
  // пока не попадём в ситуацию гонок.
  while (true) {
    String s = new String(chars);
    if (s.charAt(0) < 256 && !original.equals(s)) {
      thread.interrupt();
      return s;
    }
  }
}

Поломанные таким образом строки обладают рядом интересных свойств, например:

String a = "foo";
String b = breakIt(a);

// Они не равны между собой
System.out.println(a.equals(b));
// => false

// Последовательности символов в них совпадают
System.out.println(Arrays.equals(a.toCharArray(),
b.toCharArray()));
// => true

// compareTo считает их равными (даже несмотря на то,
// что джавадок явно говорит: "compareTo возвращает 0
// в точности тогда, когда метод equals(Object) вернул бы true")
System.out.println(a.compareTo(b));
// => 0

// У них одинаковая длина, и одна из них 
// начинается с другой (startsWith), но не наоборот.
// Потому что, в случае нормальных несломанных строк,
// строка в latin-1 не может начинаться с подстроки, 
// которой нельзя представить в latin-1.
System.out.println(a.length() == b.length());
// => true
System.out.println(b.startsWith(a));
// => true
System.out.println(a.startsWith(b));
// => false

Всё это немного странно. Обычно, ты не ожидаешь такого поведения от фундаментального Java-класса.

Прожарка кошки сквозь стену

Если вытащить из микроволновки магнетрон, можно ли сквозь стену прожарить кошку соседа?

По крайней мере, в Java мы можем прожарить строку в чужом классе!

class OtherClass {
  static void startWithHello() {
    System.out.println("hello world".startsWith("hello"));
  }
}

Если написать подобный код, то IDEA напишет предупреждение вида Результат выполнения '"hello world".startsWith("hello")' всегда равен 'true'. Вроде бы, у этого кода нет никаких входных параметров, но мы всё ещё можем заставить его вернуть false. Для этого, нужно внедрить в него сломанную строку "hello" с помощью интернирования. Мы сломаем строку, содержащую "hello" до того, как любой другой код успеет явно или неявно интернировать её, и интернируем сразу сломанную версию. В дальнейшем, все литералы "hello" в JVM окажутся сломанными.

breakIt("hell".concat("o")).intern();
OtherClass.startWithHello(); 

Челленж для самых изобретательных

Использя метод breakIt, для любой строки в latin-1 можно создать эквивалентную, но отличающуюся строку. Но это не работает для пустой строки! Потому что пустая строка не содержит символов, на которых можно было бы вызвать ситуацию гонок. Тем не менее, сломанную пустую строку всё ещё возможно получить. Доказательство этого факта остается в виде упражнения для читателя.

Конкретно: можете ли вы создать объект класса java.lang.String, для которого выражение s.isEmpty() && !s.equals("") будет равно true. Никакого читерства: для решения задачи вы можете использовать только публичное API, то есть, нельзя использовать .setAccessible для доступа к приватному коду, использовать инструментирование, и всё в таком духе.

Если вы нашли решение этой задачи, напишите его в комментариях к этому посту. А когда-нибудь в очередном Java-дайджесте мы напишем правильное решение (и обновим эту статью).

Если вам нравятся Java-новости, подписывайтесь на мою телегу @JavaWatch. Там же есть чатик, в котором всё это можно обсудить.

Статья написана при поддержке Axiom JDK (российского дистрибутива Java) и моего бара Failover Bar. Меняйте вашу джаву на проде на Axiom JDK, переходите на свежие версии JDK с исправленными багами, и приходите отмечать это в барчик!

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


  1. ris58h
    12.07.2023 12:17

    Насчёт бага автор погорячился. Из документации, на которую он же и ссылается.

    The
    * contents of the character array are copied; subsequent modification of
    * the character array does not affect the newly created string.

    Дальнейшее изменение массива не влияет на созданную строку. А вот пока она не создана (конструктор не завершил работу) очень даже влияет.

    Скорее всего, нигде в спецификации не указано, что нельзя модифицировать аргументы конструктора пока он не отработал. Как и не указано обратное. Что ж, стоит указать.

    P.S.

    На HN автор тоже никаких ссылок на спецификацию не привёл. Аргумент лишь в том что баг приняли в багтрекер.


    1. olegchir Автор
      12.07.2023 12:17

      Есть неопределенное поведение, а есть определенно веселое поведение, можно добавить это в стандарт :) Вроде и не баг, но смотрится весело!


    1. orionll
      12.07.2023 12:17
      +1

      Мало ли что написано в спецификации. Когда юзер передаёт char[] в конструктор String, он ожидает, что конструктор будет работать согласно здравому смыслу. То есть как если бы String был реализован на массивах char'ов, как было в Java 8. А в Java 8 массив копировался единожды и никакого сломанного состояния это не могло вызвать.

      В Java 9 же это поведение сломали: теперь массив копируется дважды. И теперь существует способ сломать строки, один из важнейших классов JDK. Раньше их нельзя было сломать, теперь - можно. Значит, это регресия. А значит, баг.


      1. ris58h
        12.07.2023 12:17

        Мало ли что написано в спецификации.

        Дальше не читал.


        1. orionll
          12.07.2023 12:17
          +1

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


          1. ris58h
            12.07.2023 12:17

            Вы считаете что это ошибка? Докажите.

            Пока никто не привёл ссылку на спецификацию где бы утверждалось, что конструкторы гарантируют thread safety. Похоже что они её не гарантируют. Значит это не ошибка, а ожидаемое поведение.

            Можем ли мы поправить эту "ошибку"? Почему бы и нет. Вроде и патч уже накидали. Можем ли мы изменить спецификацию и добавить гарантию thread-safety? Возможно. Но не надо голословно утверждать что это ошибка.


  1. ermadmi78
    12.07.2023 12:17
    +4

    За счёт гонок любой иммутабельный класс, конструируемый по мутабельному билдеру, можно сломать. Возможно, имеет смысл писать в документации, что билдер "is not thread safe". Но звучать это будет так же нелепо, как предупреждение в инструкции о том, что горячим утюгом можно обжечься.


    1. orionll
      12.07.2023 12:17
      +1

      Только при чём здесь билдеры? Строки конструируются из массивов, а не из билдеров.


      1. ermadmi78
        12.07.2023 12:17

        В данном случае массив выполняет роль билдера.


  1. quaer
    12.07.2023 12:17
    +1

    А если ли способ прочитать все интернированные строки из кода приложения?


    1. Ksnz
      12.07.2023 12:17
      +1

      Вроде как да. Мне так же инетересно можно ли вызвать утечку памяти переполняя пул строк, они ведь оттуда не выгружаются?


      1. quaer
        12.07.2023 12:17
        +1

        Мне найти не удалось как это сделать.

        А насчет переполнения - будет наверно OutOfMemory, если постараться. А если утечкой считать наличие в памяти неиспользуемых ресурсов, то раз они туда попали, значит, были хоть раз использованы, то есть это не утечка, так наверно.


  1. igormich88
    12.07.2023 12:17
    +2

    Чтобы получить строку для которой выполняется s.isEmpty() && !s.equals(""), достаточно этого:

    breakIt(" ").trim();

    Это происходит из за того что метод trim не меняет поле coder для UTF16-строк, что логично, так как он удаляет из начала и конца строки символы с кодом меньше или равным коду пробела (20) не трогая остальные и таким образом не может повлиять на компактность строки.
    PS насколько я понял большинство других методов работы со строками (если не все) делают дополнительные проверки и могут изменить coder.