🔒 Розділ 13 · Питання #19

Чи можна змінити значення String через рефлексію

Проте будь-яка спроба змінити рядок через рефлексію призводить до катастрофічних наслідків для стабільності всього застосунку:


🟢 Junior Level

30-секундна відповідь

  • У сучасних версіях Java (Java 17+, LTS 21): НІ, за замовчуванням не можна. Починаючи з Java 16 (JEP 396) та Java 17 (JEP 403), віртуальна машина суворо інкапсулює внутрішні пакети JDK (Strong Encapsulation). Спроба викликати field.setAccessible(true) для внутрішнього поля value класу String завершиться фатальним винятком InaccessibleObjectException.
  • У старих версіях Java (Java 8 і раніше): Так, технічно це було можливо. Через Reflection API можна було отримати доступ до приватного масиву value, зробити його доступним і посимвольно перезаписати його вміст.

Проте будь-яка спроба змінити рядок через рефлексію призводить до катастрофічних наслідків для стабільності всього застосунку:

  1. Псується весь String Pool: змінюється рядковий літерал в усіх модулях JVM (включаючи системні бібліотеки JDK).
  2. Ламаються HashMap та HashSet: закешований хеш-код hash не оновлюється, об’єкт безповоротно втрачається в бакетах колекції.
  3. Руйнується безпека: компрометуються URL, шляхи до файлів та логіни користувачів.

Як це працювало в Java 8 (Історичний приклад)

public class StringReflectionHackJava8 {
    public static void main(String[] args) throws Exception {
        String greeting = "Hello";

        // Отримуємо доступ до приватного поля value (в Java 8 це char[])
        Field valueField = String.class.getDeclaredField("value");
        valueField.setAccessible(true);

        char[] chars = (char[]) valueField.get(greeting);
        chars[0] = 'J'; // Замінюємо 'H' на 'J'

        System.out.println(greeting); // Надрукує "Jello"!
        System.out.println("Hello");  // ТАКОЖ надрукує "Jello" через String Pool!
    }
}

🟡 Middle Level

Еволюція захисту: Strong Encapsulation (Java 9 – Java 17+)

З появою модульної системи (Project Jigsaw) у Java 9 почалося планомірне закриття внутрішніх API JDK від несанкціонованого доступу:

  • Java 9–15: поява попереджень «An illegal reflective access operation has occurred».
  • Java 16 (JEP 396): інкапсуляція увімкнена за замовчуванням (Strongly Encapsulate JDK Internals by Default).
  • Java 17 (JEP 403): остаточне видалення опції --illegal-access=permit.

При спробі виконати такий код у Java 17 / 21:

Field field = String.class.getDeclaredField("value");
field.setAccessible(true); // 💥 ВИКИНЕ ВИНЯТОК!

JVM аварійно перериває виконання:

java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module

Єдиний спосіб обходу: Прапорці запуску JVM

Обійти захист у Java 17+ можна лише на етапі запуску JVM, явно передавши прапорець відкриття пакета:

java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

[!CAUTION] Використання прапорця --add-opens java.base/java.lang у Production категорично заборонено корпоративними стандартами інформаційної безпеки.


Різниця в структурі полів: Java 8 vs Java 9+ (Compact Strings)

  • У Java 8:
    private final char[] value; // UTF-16, 2 байти на символ
    
  • У Java 9+ (JEP 254 Compact Strings):
    private final byte[] value; // 1 байт на символ для LATIN1, 2 байти для UTF16
    private final byte coder;   // 0 = LATIN1, 1 = UTF16
    

    У сучасній Java для модифікації символу необхідно працювати з байтовим масивом byte[] з урахуванням прапорця кодування coder.


🔴 Senior Level

JIT C2-компіляція: Constant Folding та розсинхронізація пам’яті

Навіть якщо зловмисник обійде захист за допомогою прапорців JVM або низькорівневих нативних викликів, поведінка застосунку під оптимізатором JIT C2 стає невизначеною (Undefined Behavior):

  1. Constant Folding (Згортання констант):
    • Компілятор HotSpot C2 при оптимізації строкових літералів вважає їх абсолютними константами часу компіляції.
    • Значення літералів вбудовуються безпосередньо в машинні опкоди інструкцій процесора (Compile-Time Inlining).
  2. Шизофренія пам’яті:
    • Масив байтів у купі може бути фізично перезаписаний значенням "HACKED".
    • Проте скомпільований машинний код методу продовжує читати константу "ORIGINAL" прямо з регістрів процесора або константного сегмента коду!
    • У результаті в одному й тому самому застосунку різні потоки та методи бачитимуть взаємовиключні значення одного й того самого рядка.

Атака через sun.misc.Unsafe та його згасання (JEP 471)

Історично рефлексію можна було обійти через прямий доступ до пам’яті Unsafe:

Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

long offset = unsafe.objectFieldOffset(String.class.getDeclaredField("value"));
byte[] value = (byte[]) unsafe.getObject(str, offset);
unsafe.putByte(value, 16L, (byte) 'X'); // Прямий запис за фізичним зміщенням

Сучасний статус:

  • У сучасних версіях Java виклик objectFieldOffset для полів класів модуля java.base також блокується.
  • У Java 23/24 (JEP 471) методи прямого доступу до пам’яті в sun.misc.Unsafe оголошені застарілими до видалення (deprecated for removal) і в майбутніх релізах будуть повністю вимкнені на користь Foreign Function & Memory API (JEP 454).

4 Tricky Questions

1. Якщо строковий літерал "secret" змінити через рефлексію під Java 8, що виведе інструкція System.out.println("secret") у наступних рядках програми?

Відповідь: Інструкція System.out.println("secret") виведе нове змінене значення (наприклад, "pecret")! Строковий літерал "secret" на етапі компіляції поміщається в Constant Pool і інтернується в String Constant Pool. У коді програми всі входження "secret" посилаються на один і той самий єдиний екземпляр у купі. Зміна внутрішнього масиву цього об’єкта змінює його у всій пам’яті JVM: усі наступні виклики System.out.println("secret") (а також будь-які виклики в системних бібліотеках) прочитають мутований масив, наочно демонструючи руйнування пулу рядків.


2. Чому після модифікації символів рядка через рефлексію метод map.get(key) перестає знаходити значення в HashMap, хоча ключ передається той самий?

Відповідь: Клас String ліниво кешує свій хеш-код у приватному полі private int hash.

  1. Коли рядок кладеться в HashMap, обчислюється hashCode() за початковими символами, зберігається в полі hash, і об’єкт поміщається у відповідний бакет таблиці.
  2. Коли рефлексія змінює масив value, поле hash залишається старим, оскільки рефлексія не перераховує кеш.
  3. Проте метод String.equals() при перевірці колізій у бакеті порівнює фактичні байти масиву value.
  4. При спробі пошуку за новим рядком із тим самим новим текстом його хеш-код не збіжиться зі старим закешованим хешем у map. А при пошуку за старим рядком перевірка equals() поверне false, оскільки байти в масиві вже інші. Об’єкт стає неможливо знайти ні за старим, ні за новим значенням.

3. Чи можна в Java 17 обійти InaccessibleObjectException за допомогою власного SecurityManager?

Відповідь: Ні, не можна!

  1. Механізм сильної інкапсуляції модулів (Strong Encapsulation, JEP 403) реалізований на фундаментальному рівні рантайму JVM та модуля java.base, а не через SecurityManager.
  2. Перевірка прав модуля виконується методом AccessibleObject.checkCanSetAccessible() до будь-яких перевірок SecurityManager.
  3. Більше того, починаючи з Java 17 (JEP 411), сам механізм SecurityManager визнано застарілим до видалення (deprecated for removal) і поступово демонтується з платформи OpenJDK. Єдиний спосіб відкрити доступ — аргумент командного рядка --add-opens.

4. Як поведеться JIT C2 компілятор, якщо в методі виконується return "status: " + "OK";, а літерал "OK" був змінений у пам’яті через Unsafe?

Відповідь: Метод поверне початковий рядок "status: OK"! Вираз "status: " + "OK" складається з двох констант часу компіляції. Компілятор javac ще на етапі збирання об’єднує їх у єдиний рядковий літерал "status: OK" (Constant Folding), поміщаючи його окремим рядком у таблицю констант .class-файлу. Літерал "status: OK" має свій власний незалежний масив байтів у String Pool. Мутація рядка "OK" змінить лише об’єкт "OK", а скомпільований рядок "status: OK" залишиться абсолютно недоторканим.


🎯 Шпаргалка для інтерв’ю

Чи можна змінити String через рефлексію?

  • Java 17+: НІ, викидається InaccessibleObjectException.
  • Як обійти: тільки через прапорець запуску --add-opens java.base/java.lang=ALL-UNNAMED.
  • У Java 8: було можливо через setAccessible(true) до поля value.

Наслідки зміни String через рефлексію

| Область | Що відбувається | | :— | :— | | String Pool | Змінюються всі рядкові літерали з таким текстом в усій JVM | | HashMap / HashSet | Закешований hash не оновлюється $\rightarrow$ ключі назавжди губляться | | Безпека | Обхід перевірок безпеки (TOCTOU атаки) | | JIT C2 | Розсинхронізація через Constant Folding (JIT інлайнить старе значення) |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «У Java 17 рефлексія до String працює точно так само, як у Java 8» — груба некомпетентність у сучасних версіях Java.
  • ❌ «Зміна рядка через рефлексію зачіпає тільки цю конкретну змінну» — літерали беруться з пулу рядків, змінюється значення в усій JVM.
  • ❌ «HashMap продовжить знаходити об’єкт, бо посилання те саме» — метод equals() або hashCode() зламають пошук у бакеті.

Пов’язані питання