Бассейн со строками - (не)Уникальный опыт

Бассейн со строками


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

Речь пойдет об организации памяти в JVM и таком явлении как String pool, а также почему нельзя так просто взять и удалить секретную информацию из памяти android приложения.

Внутри много картинок!

/img/string-pool/kdpv.jpg



Очень поверхностный ликбез

Все строки в JVM являются неизменяемыми и создаются в области памяти, которая называется “куча” (heap). Внутри кучи есть еще одна область памяти, которая называется string pool. В эту область памяти, строки попадают автоматически, если создаются с помощью двойных кавычек. Строки могут быть также помещены в эту область памяти вручную при вызове метода intern(), а сам процесс помещения строк в пул называется интернированием.

/img/string-pool/String-Pool-Java1.png

Зачем это нужно? Ради оптимизации! Вместо того чтобы создавать в куче много одинаковых строк, можно создать одну строку в пуле и далее ссылаться на нее. Это здорово экономит память.

Казалось бы, все хорошо, но есть пара нюансов, о которых следует помнить:

  1. Содержимое строк нельзя менять. При перезаписи строкового значения, будет создан новый объект, а старый ждет пока его соберет сборщик мусора.
  2. Сборщик мусора не собирает строки из пула

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

Погружение в проблему

/img/string-pool/ads-triathlon-060722.jpg

Чтобы наше путешествие в глубины JVM было удачным, нужно подготовить инструменты для создания дампов и последующего анализа памяти. Для создания дампов я использую самописный скрипт на bash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash

if [ -z $1 ]
then
    echo "Usage: ./dumper <apk-package>"
    exit
fi

if ! command -v adb &> /dev/null
then
    echo "adb could not be found.  Add your \$ANDROID_HOME/platform-tools to PATH"
    exit
fi

if ! command -v hprof-conv &> /dev/null
then
    echo "hprof-conv could not be found.  Add your \$ANDROID_HOME/platform-tools to PATH"
    exit
fi

DEFAULT_HPROF_FILE=dump_$(date +%s).hprof

adb shell am dumpheap $(adb shell ps | grep $1 | awk '{print $2}') /data/local/tmp/$DEFAULT_HPROF_FILE &&
adb pull /data/local/tmp/$DEFAULT_HPROF_FILE &&
hprof-conv -z $DEFAULT_HPROF_FILE $1.hprof &&
adb shell "rm /data/local/tmp/$DEFAULT_HPROF_FILE"

if [[ -f $1.hprof ]]
then
    echo "👨‍🔬 Dumped to: $1.hprof"
else
   echo "🙉 Something went wrong!" 
fi

А для разных этапов анализа нам понадобятся следующие инструменты:

  1. Утилита strings и ее ближайший друг grep
  2. Android Studio Memory Profiler
  3. Парсер hprof файлов

Как это все будет задейстовано я покажу далее, а пока начнем с очень простого и надуманного примера.

Тому, кто захочет это повторить - придется делать очень много дампов. Более ленивых людей призываю просто наслаждаться происходящим ;)

Простой, надуманный пример

var token = "super_secret_token1337"
Log.d("Debug", token)

token = ""
System.gc()
Log.d("Debug", token)

Результат работы этого кода довольно предсказуем:

/img/string-pool/p1.png

Был токен и не стало. Но, не все так однозначно. Давайте сделаем дамп памяти запущенного приложения и поищем в ней этот токен.

/img/string-pool/d1.png
Строковый литерал предсказуемо оказался в пуле строк и даже принудительная сборка мусора не смогла его оттуда удалить. Используя знания полученные о том, что строки созданные с помощью конструктора в пул автоматически не попадают, попробуем сделать так чтобы наш секретный токен не оседал в памяти. Собирать строку будем через CharArray, чтобы вообще отказаться от строковых литералов.

var token = String(
    charArrayOf(
        's',
        'u',
        'p',
        'e',
        'r',
        '_',
        's',
        'e',
        'c',
        'r',
        'e',
        't',
        '_',
        't',
        'o',
        'k',
        'e',
        'n',
        '1',
        '3',
        '3',
        '7'
    )
)
Log.d("Debug", token)
token = ""
System.gc()
Log.d("Debug", token)

/img/string-pool/d2.png
Гипотеза сработала! CharArray всех спас! На этом можно было бы закончить статью фразой “Используйте CharArray и да пребудет с вами безопасность”. Но давайте не будем торопиться с выводами. Немного модифицируем код и поменяем содержимое токена для наглядности:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var token = String(
    charArrayOf(
        'n',
        'o',
        't',
        '_',
        's',
        'o',
        '_',
        's',
        'e',
        'c',
        'r',
        'e',
        't',
        '_',
        't',
        'o',
        'k',
        'e',
        'n',
        '1',
        '3',
        '3',
        '7'
    )
)
val hw = findViewById<TextView>(R.id.hworld)
hw.setOnClickListener {
    Toast.makeText(this, "$token", Toast.LENGTH_SHORT).show()
    token = ""
    System.gc()
}

Запускаем приложением, нажимаем на TextView и…

/img/string-pool/d3.png
Упс… А вот и магия Хогвартса перестала работать =( Почему так произошло? Давайте разберемся. Для этого нужно загрузить дамп в профилировщик Android Studio и найти ту самую строку чтобы посмотреть ссылки на нее.
*кликабельно

*кликабельно

А вот и причина - строка утекла через поле mText класса Toast. Но как так вышло?! Опытные android разработчики и прочие java-джедаи уже знают ответ. Для всех остальных, я открою страшный секрет: параметры в Java всегда передаются по значению. Другими словами - методы работают с копией данных, а не с оригиналом.

Более жизненный пример

До этого строки создавались “руками”, и у вас могли появиться сомнения в адекватности проводимых исследований. Все верно. Всегда нужно сомневаться в том, что читаешь в интернете! Поэтому все дальнейшие исследования будем проводить на классическом, клиент-серверном приложении, которое получает токены от сервера по https и сохраняет их в зашифрованном виде. Приложение забирайте тут.

/img/string-pool/appscreen.png

Основной флоу работы приложения выглядит следующим образом:

*кликабельно

*кликабельно

В ответ на запрос приходит модель описывающая залогиненного юзера и среди прочих данных возвращает токен для доступа к API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "id": 15,
  "username": "kminchelle",
  "email": "kminchelle@qq.com",
  "firstName": "Jeanne",
  "lastName": "Halvorson",
  "gender": "female",
  "image": "https://robohash.org/autquiaut.png?size=50x50&set=set1",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTUsInVzZXJuYW1lIjoia21pbmNoZWxsZSIsImVtYWlsIjoia21pbmNoZWxsZUBxcS5jb20iLCJmaXJzdE5hbWUiOiJKZWFubmUiLCJsYXN0TmFtZSI6IkhhbHZvcnNvbiIsImdlbmRlciI6ImZlbWFsZSIsImltYWdlIjoiaHR0cHM6Ly9yb2JvaGFzaC5vcmcvYXV0cXVpYXV0LnBuZz9zaXplPTUweDUwJnNldD1zZXQxIiwiaWF0IjoxNjM1NzczOTYyLCJleHAiOjE2MzU3Nzc1NjJ9.n9PQX8w8ocKo0dMCw3g8bKhjB8Wo7f7IONFBDqfxKhs"
}

Пройдем этот сценарий как есть, потом сделаем дамп и посмотрим где в памяти оседает токен.

*кликабельно

*кликабельно

Из скрина видно, что токен ссылается несколько объектов из показанной выше цепочки вызовов. Попробуем теперь “очистить память” после сохранения токена в защищенное хранилище и посмотреть, что в итоге попадет в дамп. Сначала нужно доработать метод setLoggedInUser() убрав из памяти ненужный токен:

private fun setLoggedInUser(loggedInUser: LoggedInUser) {
    this.user = loggedInUser
    preferences.edit {
        putString("accessToken", user?.token)
    }.also { // <- вся магия здесь
        user?.token = ""
        System.gc()
    }
}

Запуск, дамп, и…

*кликабельно

*кликабельно

На привычном месте, среди длинных строк, токена больше нет. Только его зашифрованный вариант, который сохранился в преференсы. Неужели получилось? Спойлер: нет 🤪

В профилировщике студии нет поиска по строкам (или я не нашел), поэтому пройдемся по дампу утилитой strings и погрепаем jwt токены:

*кликабельно

*кликабельно

Все на месте. Пришло время погрузится в нюансы формата HPROF и выяснить в какой области памяти оказался вроде бы надежно удаленный токен.

Покопаемся в куче

К дальнейшему анализу дампов, помимо уже имеющихся инструментов, подключим парсер формата hprof. Я взял готовую реализацию отсюда и немного допилил ее напильником чтобы она вообще работала, а не валилась с ошибкой. Доработанный вариант лежит здесь(ветка research) Стоит сказать, что до того, как найти эту реализацию я начал писать свою и концепт получился даже неплохим, но в итоге я решил не изобретать велосипед и взять что-то более-менее готовое. Если кому-то захочется написать такой парсер самостоятельно, то начать нужно отсюда.

Строки в JVM представляют из себя массив символов - char[] в кодировке UTF-16. А значит искать мы будем записи с типом HPROF_GC_PRIM_ARRAY_DUMP:

HPROF_GC_PRIM_ARRAY_DUMP dump of a primitive array
    id         array object ID
    u4         stack trace serial number
    u4         number of elements
    u1         element type
               4:  boolean array
               5:  char array
               6:  float array
               7:  double array
               8:  byte array
               9:  short array
               10: int array
               11: long array
    [u1]*      elements

Записи (records) это элементы из которых состоит hprof файл. Они делятся на records и sub-records, и имеют разную структуру в зависимости от типа, который определяется тегом (первый байт записи).

Начнем с извлечения всех записей содержащих массивы символов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func main() {
  ...

  hprofFile, err := os.Open(filename)
  if err != nil {
  	log.Fatalln(err)
  }
  defer hprofFile.Close()

  hprofParser := parser.NewParser(hprofFile)

  // Mandatory thing 🤯
  _, err = hprofParser.ParseHeader()
  ...

  for {
    record, err := hprofParser.ParseRecord()
    if err != nil {
      if errors.Is(err, io.EOF) {
        fmt.Println("\nComplete!")
        break
      }
	  log.Fatalln(err)
	}

    if subRecord, ok := record.(*hprofdata.HProfPrimitiveArrayDump); ok {
      if subRecord.ElementType == hprofdata.HProfValueType_CHAR {
	    content := decodeUTF16(subRecord.Values, binary.BigEndian)
	    if strings.Contains(content, needle) {
	      fmt.Println(content)
	    }
      }
    }
  }
}

Запуск парсера выдает интересные результаты. Совпадения нашлись, но их значительно меньше чем было при проходе утилитой strings. Всего два результата.

*кликабельно

*кликабельно

На этом месте нужно остановиться и немного подумать. Взглянув на возможные типы элементов, можно предположить, что строки также могут быть представлены как массивы байтов, а не символов. Модифицируем немного код парсера чтобы проверить эту гипотезу:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
for {
  record, err := hprofParser.ParseRecord()
  if err != nil {
   if errors.Is(err, io.EOF) {
    fmt.Println("\nComplete!")
      break
    }
    log.Fatalln(err)
  }

  if subRecord, ok := record.(*hprofdata.HProfPrimitiveArrayDump); ok {
    var elementsType string
    var content string

    switch subRecord.ElementType {
    case hprofdata.HProfValueType_BYTE:
      elementsType = fmt.Sprintf("\033[35m%s\033[0m", subRecord.ElementType)
      content = string(subRecord.Values)
    case hprofdata.HProfValueType_CHAR:
      elementsType = fmt.Sprintf("\033[33m%s\033[0m", subRecord.ElementType)
      content = decodeUTF16(subRecord.Values, binary.BigEndian)
    }

    if strings.Contains(content, needle) {
      fmt.Printf("\033[32m[*] Elements type:\033[0m %s; \033[32mContent:\033[0m %s\n", elementsType, content)
    }
}
*кликабельно

*кликабельно

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

И что теперь делать?

Порассуждаем. Токен, попал в несколько мест в куче в виде массивов двух типов, char[] и byte[], но это прозошло неявно, т.к. никаких массивов мы в коде не создавали. Что если попробовать получить больше контроля над этими всеми процессами и вместо строк начать использовать массивы байтов? А еще, байтовые массивы не являются иммутабельными и их можно затирать. Звучит как план!

На самом деле, можно пойти еще дальше и вместо байтового массива использовать байтовый буфер (ByteBuffer) с прямой аллокацией памяти.

A direct buffer refers to a buffer’s underlying data allocated on a memory area where OS functions can directly access it.

Начать использовать такой буфер нужно как можно раньше (ниже?) в архитектурном смысле. Самая первая точка, где мы получаем контроль над происходящим, находится в процедуре десериализации. Все, что происходит до этого является для нас черным ящиком. Поэтому начнем с написания адаптера, который будет сериализовать токен в ByteBuffer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class ByteBufferAdapter : TypeAdapter<ByteBuffer>() {
    override fun write(out: JsonWriter?, value: ByteBuffer?) {
        ...
    }

    override fun read(`in`: JsonReader): ByteBuffer {
        return `in`.nextString().let {
            ByteBuffer.allocateDirect(it.length).put(it.encodeToByteArray())
        }
    }
}

Даже на этом уровне нам уже приходится иметь дело со строками, но будем надеятся, что они там как-нибудь сами пропадут ;) Далее доработаем доменную модель пользователя:

1
2
3
4
5
6
7
data class LoggedInUser(
    ...

    @SerializedName("token")
    @JsonAdapter(ByteBufferAdapter::class)
    var token: ByteBuffer
)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun ByteBuffer.wipeDirectBuffer(random: Random = Random()) {
    if (!this.isDirect) throw IllegalStateException("Only direct-allocated byte buffers can be meaningfully wiped")

    val arr = ByteArray(this.capacity())
    this.rewind()

    random.nextBytes(arr)
    this.put(arr)
}
...
private fun setLoggedInUser(loggedInUser: LoggedInUser) {
    this.user = loggedInUser
    user?.let { u ->
        preferences.edit {
            putString("accessToken", String(u.token.array()))
        }.also {
            u.token.wipeDirectBuffer()
            System.gc()
        }
    }
}

Тут мы тоже без строк не обойдемся, к сожалению. SharedPreferences.Editor просто не умеет сохранять данные в виде массива байт. Но может нам повезет и это использование строк тоже никто не заметит? Тем более строка создается через конструктор, в пул строк попасть не должна, а значит мы все еще в безопасности!

Запускаем приложение, получаем токен с сервера, делаем дамп и смотрим на результат

*кликабельно

*кликабельно

В голову сразу приходит вот эта картинка

/img/string-pool/frogsnake.jpeg

Да, результат стал еще хуже чем был. И это еще не все данные, остальные не уместились на скриншоте. Что тут сказать… Не прокатило 🤪

Показанный выше способ с байтовыми буферами - вполне рабочий. Проблема не в нем. Она в том, что мы не контролируем большую часть того, что происходит с данными в приложении. Как с ними обращаются библиотеки, которые мы используем. Куда и как они их копируют и во что конвертируют. Чтобы избавиться от этой проблемы - нужно контролировать вообще все. В том числе сетевые библиотеки, сериализаторы, сохранять данные на диск в собственном формате. Все это должно быть самописным и не использовать JVM строки. Никто в здравом уме не будет этим заниматься.

Заключение

Стоило ли писать целую статью, если можно было в 3-5 предложений обрисовать ситуацию? Я считаю, что стоило. Хотябы для того чтобы прекратить поиск точки G возможностей затирать строки в памяти и сделать это осознанно. С доказательной базой, с инструментами для проверки гипотез и четким пониманием границ реализуемых механизмов. Я надеюсь, что прочитав ее вы узнали что-то новое и стали лучше понимать ту платформу с которой имеете дело в качестве разработчика или хакера.

Всех с наступающим Новым Годом! 🥳

Ссылки


Смотрите также