Спойлер: ДА.

Элитный инструмент для хакинга; от вас скрывают, что он уже у вас есть

Прежде чем писать эксплойт, нам нужен баг. А если багов нет, то приходится быть изобретательными — тут нам на помощь приходит внесение неисправностей. Внесение неисправностей (fault injection) может принимать множество различных форм, в том числе быть управляемым ПО повреждением данных, глитчингом питания, тактовым глитчингом, электромагнитными импульсами, лазером и так далее.

Для внесения аппаратных неисправностей обычно требуется специализированное (и дорогостоящее) оборудование. Его цена связана с высокой точностью времени и места внесения неисправностей. Для снижения цен было совершено множество отважных попыток такими проектами, как PicoEMP на основе RP2040 и вплоть до «Laser Fault Injection for The Masses». (Неожиданная популярность RP2040 связана с его низкой ценой и периферией PIO, обеспечивающей ввод-вывод с чёткими таймингами и задержками.)

Какое-то время назад я прочитал об использовании соединённой с индуктором пьезоэлектрической зажигалки для барбекю в качестве низкобюджетного устройства для внесения электромагнитных неисправностей (electro-magnetic fault injection, EMFI). Меня захватила эта идея. Я задался вопросом, а чего можно добиться при помощи такого примитивного устройства? На тот момент мне пришёл в голову лишь эксплойтинг работающей на Arduino программной реализации AES при помощи DFA. И это сработало!

Но этого мне было мало. Я хотел эксплойтить что-то более «реальное», но на то время у меня закончились идеи.

Перенесёмся в наше время: пару недель назад уже ожидалось объявление о выпуске Nintendo Switch 2. Предполагается, что системное ПО Switch 2 будет практически таким же, как и на Switch 1, а программные баги для изучения у нас закончились. Поэтому у меня возникла мотивация смахнуть пыл с моих навыков взлома оборудования, и я вернулся к мыслям о низкобюджетном EMFI.

Подопытный


Как и у любого уважающего себя хакера, у меня есть куча старых ноутбуков. Я выбрал Samsung S3520 с CPU Intel i3-2310M и 1 ГБ ОЗУ DDR3. Он был изготовлен в 2011 году, поэтому он достаточно новый, чтобы на нём можно было запускать лёгкие десктопные дистрибутивы Linux (я выбрал Arch), но достаточно отстойный, чтобы не жалко было его сломать.

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

Я решил, что самой физически уязвимой частью ноутбука будет шина DDR, соединяющая память DRAM с остальной системой.

Если вы когда-нибудь видели модуль ноутбучной памяти (SODIMM), то замечали, что у него есть множество контактов. Среди них присутствуют 64 контакта «DQ» (пронумерованных от DQ0 до DQ63), передающих биты данных в обоих направлениях (чтение или запись). Я подумал, что если смогу вносить неисправности на одном из этих контактов, то получится сделать что-нибудь интересное.

Поэкспериментировав, я пришёл к следующей аппаратной системе:

Если я посчитал правильно, этот контакт соответствует контакту 67, то есть DQ26

Это всего лишь один резистор (на 15 Ом) и один провод, припаянные к DQ26. Провод используется как антенна, улавливающая любые электромагнитные помехи поблизости и сбрасывающая их прямиком в шину данных. Резистор (который может быть и совершенно не нужен) требуется только для того, чтобы помехи не оказались слишком велики и не помешали обычной работе памяти — я хочу, чтобы глитчи возникали только по необходимости, а не постоянно.

Слева провод-антенна, справа — элитный хакерский инструмент. Не обращайте внимание на изоленту, этот ноутбук уже многое пережил.

Я обнаружил, что щелчков обычной пьезоэлектрической зажигалкой (без индукционных катушек) рядом с проводом антенны достаточно для устойчивого создания ошибок памяти, которые видны в memtest. Обратите внимание, что обе показанные ошибки соответствуют смене значения бита 29.

Почему бита 29, если я подпаялся к DQ26? Честно говоря, толком я не знаю; или я неправильно посчитал контакты, или материнская плата ноутбука меняет местами некоторые из линий передачи данных. Насколько я знаю, такая перемена мест линий передачи данных допустима (она может упростить прохождение сигналов).

Мы не особо можем управлять временем внесения неисправностей (ограничением становится скорость реакции моего пальца), но когда они возникают, мы можем с большой долей уверенности предполагать, что они всегда меняют значение одного и того же бита любой конкретной 64-битной операции чтения или записи.

Эксплойтим инвертирование битов в CPython


Для начала я хотел попробовать написать эксплойт «побега из песочницы» для CPython. Это чисто исследовательская работа, ведь CPython даже не работает в песочнице, и можно просто выполнить os.system("/bin/sh"), но для начала мне нужно было что-то простое, а я уже был знаком с внутренним устройством CPython. Мои объяснения этого эксплойта будут довольно поверхностными, потому что специфика не так интересна, и в первую очередь мне хочется объяснить концепцию.

Для эксплойтинга CPython я воспользовался проводом, припаянным к DQ7 вместо DQ26, причины этого будут объяснены ниже.

Объекты CPython находятся в куче со сборкой мусора. Объект имеет заголовок, содержащий его refcount, после которого идёт указатель на объект его типа, а затем другие поля, относящиеся к типу. Нам интересны два типа объектов, bytes и bytearray. Объекты bytes неизменяемы, а объекты bytearray изменяемы.

У объекта bytes есть поле длины, за которым идут сами данные (как часть того же распределения в куче памяти).

Объект bytearray имеет поле длины, за которым идёт указатель на буфер хранения самих данных.

В основе моей стратегии эксплойтинга лежит создание объекта bytes, содержащего внутри фальшивую структуру bytearray. Фальшивый объект bytearray — это просто данные, мы не можем с ними ничего сделать, но если мы хитростью получим у CPython ссылку (указатель) на этот фальшивый объект, то сможем создать произвольный примитив чтения/записи в память (потому что мы выбрали поля длины и указателя bytearray самостоятельно).

Как же получить ссылку на фальшивый объект? Вспомним, что нашим исходным «примитивом эксплойта» была возможность инвертировать бит 7 в 64-битном слове. Это эквивалентно прибавлению или вычитанию 128 (27) из указателя. Если наш фальшивый bytearray находился бы на смещении +128 байтов внутри объекта bytes, то глитч указателя на объект bytes превратит его в указатель на сконструированный нами объект bytearray (с вероятностью 50%).

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

Важно помнить о том, что мы выполняем глитчинг шины памяти, а не содержимого памяти (как в чём-то наподобие Rowhammer). Мы вмешиваемся только в операции чтения и записи, данные «в покое» по большей мере остаются в безопасности. Решение здесь заключается в спаминге доступов к указателю в памяти, который мы хотим заглитчить. Если 99% активности шины будет заполнено операциями с возможностью эксплойта, то глитч в произвольный момент времени имеет (теоретически) вероятность 99% попасть именно туда, куда нам нужно.

Если мы будем считывать один и тот же указатель в цикле, то почти ничего не случится, потому что CPU кэширует данные, чтобы избежать необязательного доступа к DRAM (кэш быстр, а DRAM по сравнению с ним — медленная и имеет высокие задержки).

Я решил заполнить большой массив (больше, чем кэш CPU на 3 МиБ) ссылками на один и тот же объект. Тогда я смогу последовательно получать доступ в цикле к элементам массива, заставляя CPU получать их каждый раз из DRAM, и проверять, поменялось ли их значение. Внутренний цикл выглядит так:

# «жертва» — это описанный выше подготовленный объект `bytes`
spray = (victim,) * 0x100_0000 # Я использую кортеж вместо массива, принцип тот же

for obj in spray:
    if obj is not victim: # при условиях отсутствия глитчей это всегда будет false
        print("Found corrupted ptr!")
        assert(type(obj) is bytearray)

Большую часть времени это работать не будет, поэтому всё это выполняется внутри другого большого цикла, пока не сработает (или пока не вылетит система).

Здесь нам пригодится ключевое слово is Python; по сути, это операция сравнения указателей, позволяющая нам проверять, изменился ли указатель. Если визуализировать объекты в памяти, то это выглядит так:


«Глитчевый» указатель показан красным, теперь он может получить доступ к фальшивому объекту bytearray. Сам глитч может произойти и при чтении, и при записи, в конечном итоге результат будет один. Остальная часть эксплойта не особо интересна; я подготовил повторяющийся примитив чтения/записи, а затем сконструировал объект Function, выполняющий переход к шелл-коду. Полные исходники можно посмотреть здесь. У скрипта также есть опция (переменная TESTING), программно вызывающая симулированное инвертирование битов, что полезно для тестирования без аппаратной системы.

Эксплойтим инвертирование битов в Linux


Мы разогрелись и теперь готовы к настоящему пересечению границ безопасности. Можно ли получить рут через непривилегированного пользователя Linux? Чтобы ответить на этот вопрос, нужно сначала разобраться с тремя базовыми концепциями:

  • Кэшированием памяти (о котором я уже упоминал).
  • Виртуальной памятью и таблицами страниц.
  • Буфером ассоциативной трансляции (Translation Lookaside Buffer, TLB).

▍ Кэширование памяти


Как говорилось выше, DRAM относительно медленная и имеет высокие задержки. Поэтому у CPU на кристалле есть кэши, работающие быстрее. Эти кэши состоят из нескольких уровней (L1, L2, L3) с разными параметрами размеров/локальности/задержек, но в нашем случае важен лишь кэш L3 (самый большой слой, у моего процессора 3 МиБ). Если данные в текущий момент находятся в кэше («попадание в кэш»), то CPU не понадобится для их считывания обращаться к DRAM. Если же произошёл «промах кэша», то CPU придётся обратиться к DRAM.

Наименьшая величина памяти с точки зрения кэширования — это строка кэша, на моём ноутбуке представляющая собой блок в 64 байта. Это значит, что если считать даже единственный некэшированный байт, то выполняется полная 64-байтная операция чтения из DRAM. Как вы помните, сама шина данных DRAM имеет ширину всего 64 битов, то есть считывание происходит «серией» из восьми операций последовательного доступа.

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

▍ Виртуальная память


В былые времена простые CPU наподобие MOS 6502 имели «плоское» пространство адресов. Если программа пыталась выполнить чтение из адреса 0xcafe, то CPU физически задавал на контактах 16-битной адресной шины 0xcafe (0b1100101011111110) и считывал байт из этого места. Если не учитывать аппаратные трюки наподобие переключения банков, запрошенный адрес всегда совпадал с получаемым. Всё просто!

Мой компьютер 6502, собранный много лет назад (разъём USB-C я добавил недавно). Обратите внимание на контакты данных D0-D7 и контакты адресов A0-A15.

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

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

В x86-64 (и в большинстве современных архитектур) эта косвенность реализована при помощи концепции страничной организации памяти.

Пространство виртуальных адресов разделено на страницы по 4 КиБ и древовидную иерархию таблиц страниц, определяющие, как CPU (а точнее MMU) должен декодировать виртуальные адреса и отображать их в физические страницы. На этой платформе существует четыре слоя таблиц страниц. Официально у каждого из слоёв есть собственное имя, но я буду называть их «уровень 3»-«уровень 0», где уровень 3 — это корень дерева. Сама таблица страниц — это страница размером 4 КиБ, содержащая массив из 512 элементов таблицы страниц (Page Table Entry, PTE), каждый из которых является 64-битной структурой. PTE указывает или на физический адрес таблицы уровней следующего уровня (в случае уровней с 3 по 1), или на физический адрес «целевой» страницы (в случае уровня 0).

Физический адрес корневой таблицы страниц хранится в регистре CR3 CPU.

Сами PTE имеют следующую структуру (схема из osdev wiki):


Единственное, что нас интересует — это часть с адресом посередине. Если выполнить маскирование всех битов флагов, то у нас останется адрес физической памяти (так как страницы всегда выровнены по 4 КиБ, младшие биты всегда равны нулю).

Более подробное объяснение со схемами можно найти в этой статье из «Writing an OS in Rust» (стоит отметить, что там используются номера уровней с 4 по 1, что, вероятно, удобнее).

▍ Буфер ассоциативной трансляции


Если вам кажется, что процесс обхода таблиц страниц для определения виртуальных адресов затратен, то вы правы. Здесь на помощь приходит TLB. Это специализированное оборудование внутри CPU, кэширующее мэппинги виртуальных и физических адресов. TLB имеет конечный размер; не знаю, какой он на моём ноутбуке, но судя по всему, где-то порядка 1024 записей (каждая запись соответствует мэппингу целой страницы).

Стратегия эксплойта


Источником вдохновения для моей стратегии эксплойта стали элементы эксплойта Rowhammer Марка Сиборна. Основная цель заключается в том, чтобы таблица страниц для нашего собственного процесса была отображена в память, доступную пользователю. Добившись этого, мы можем модифицировать PTE внутри неё, чтобы предоставить себе доступ к произвольной физической памяти, что даёт нам ключи от всех дверей.

Вместо того, чтобы пытаться управлять расположением структур в физической памяти (наверно, это версия фен-шуй кучи для физической памяти?), я буду заполнять максимальный объём физической памяти таблицами страниц уровня 0. На практике я заполню ровно 50% физической памяти.

Завершив с заполнением, я буду пытаться в цикле получить доступ к мэппингам чтения/записи таким образом, чтобы обойти TLB (потому что количество мэппингов превышает размер TLB), принудительно выполняя обход таблицы страниц при каждом доступе. Во время этого обхода я хочу вызвать глитч шины памяти, чтобы повредить бит 29 чтения PTE уровня 0. Если мне повезёт (шансы примерно 50%), то глитч сместит физический адрес на тот, куда указывает PTE, заставив его указывать на таблицы страниц уровня 0, которыми мы ранее заполнили память.

Теоретически это сработает с инвертированием любых битов в позициях от 29 (соответствует смещению на 512 МиБ) до 12 (соответствует смещению на 4 КиБ). Важно только то, что в конечном итоге PTE будет указывать «куда-то ещё», а поскольку мы заполнили примерно 50% физической памяти таблицами страниц с возможностью эксплойта, шансы на успех велики. Следовательно, припаивать провод антенны, вероятно, не совсем обязательно, если вы сможете генерировать достаточно сильные электромагнитные помехи (хотя при этом гораздо выше шансы сбоев или даже полной поломки всей системы).

Вот визуализация того, как неисправность влияет на таблицы страниц:


Каждый блок на этой схеме обозначает страницу физической памяти на 4 КиБ. Внутри памяти они расположены более-менее случайно, и для логики эксплойта это не так важно. Важно то, что на что указывает. Глитчевый PTE (показан красным) должен показывать на страницу чтения/записи, но теперь он указывает на другую таблицу страниц уровня 0, предоставляя к ней доступ, как будто бы это была обычная страница чтения/записи. На практике существуют ещё тысячи таких страниц уровня 0, и глитчевый PTE мог бы указывать на любую из них, но на схеме мы можем уместить лишь несколько.

Как же заполнить память таким большим количеством таблиц страниц уровня 0?

Для начала я создаю относительно новую фичу Linux под названием memfd. Она выполняет ту же роль, что и файл /dev/shm/ в эксплойте Марка Сиборна, но вообще без необходимости касаться файловой системы. Затем я выполняю системный вызов mmap для многократного мэппинга того же буфера в память. Я использую опцию MAP_FIXED, чтобы принудительно выровнять каждый мэппинг в виртуальной памяти на 2 МиБ, что гарантирует создание каждый раз новой таблицы страниц уровня 0. В Linux есть ограничение на количество мэппингов (около 216) (VMA, Virtual Memory Area), разрешённое каждому процессу, так что я делаю каждый мэппинг длиной 32 МиБ. Это значит, что каждое генерирует 16 таблиц страниц уровня 0. Хотя каждый мэппинг занимает по 32 МиБ пространства виртуальной памяти, все PTE указывают на одинаковые физические страницы. Затраты физической памяти на каждый мэппинг состоят только из таблиц страниц уровня, а потому я могу заполнять память любым их количеством, пока она не закончится.

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

MMU не сразу «заметит» изменения в PTE, потому что мэппинги виртуальных адресов в физические кэшируются буфером TLB. Каждый раз, когда мы их меняем, нужно сбрасывать TLB. Не существует прямого способа сделать это в пользовательском пространстве (или я его не знаю?), поэтому я просто выполняю в цикле доступ к нескольким тысячам мэппингов чтения/записи, заставляя TLB заполняться новыми значениями и удалять старые.

Теперь у нас есть полный доступ чтения/записи ко всей физической памяти! Дальше мы можем использовать множество разных стратегий; я снова использовал как источник вдохновения эксплойт Rowhammer. Я открываю исполняемый файл /usr/bin/su (который является setuid root) номинально в режиме только для чтения (у меня нет разрешения на запись!) и выполняю mmap для его первой страницы. Затем я сканирую всю физическую память, пока не найду ту же страницу. После нахождения физической страницы у меня появляется полный доступ записи к ней, и я заменяю её собственной крошечной (менее 4 КиБ) программой ELF, порождающей шелл рута. По сути, это отравляет кэш страниц Linux.

Когда в следующий раз кто-нибудь (я) попытается вызвать двоичный файл su, Linux будет знать, что он уже находится в первой странице памяти, и не попытается снова считать его с диска. То есть он повторно использует ту же кэшированную страницу и начнёт исполнять наш инъецированный ELF. Game over!

Мой инъецированный ELF также выполняет сброс кэша страниц (echo 1 > /proc/sys/vm/drop_caches), чтобы когда в следующий раз кто-то вызовет su, он снова будет работать нормально.

Полные исходники моего эксплойта можно найти здесь.

Вот демонстрационное видео (простите за небольшую размытость):

В этом прогоне мне невероятно повезло: обычно для получения хорошего глитча требуется множество щелчков зажигалкой. В видео также не показано множество попыток, приводивших к вылету всей системы! Не знаю точно общий уровень надёжности эксплойта, я не замерял его тщательно. Когда экран ноутбука отключён и я подключаюсь через SSH, то по ощущениям он примерно равен 50%. Но когда я в графической оболочке, как в демо, надёжность ближе к 20%. В этой системе используется интегрированная графика, так что, вероятно доступ к памяти со стороны GPU создаёт помехи эксплойту. Кроме того, работает множество фоновых сервисов (pipewire, sshd, systemd и так далее), а также включена подкачка. Я хотел создать достаточно реалистичную десктопную среду Linux; вероятно, отключение всего этого повысит надёжность.

Если бы в ноутбуке было установлено больше ОЗУ, то я бы мог заполнить таблицами страниц ещё больший процент, что также бы повысило общую надёжность эксплойта.

Практическое применение


Как бы ни было круто моё локальное повышение привилегий (LPE) Linux, у меня и так уже есть рут на этом ноутбуке, потому что он мой. Можно ли с этим сделать что-то более «полезное»?

Я не особо люблю играть на PC (предпочитаю консоли Nintendo), но меня всегда коробило, когда я видел «античитерское» ПО, использующее технологии наподобие TPM для ограничения списка программ, которые можно запускать на остальной части системы. Возможно, надёжная LPE Windows при помощи EMFI позволила бы геймерам снова вернуть контроль над своими PC без влияния на статус аттестации TPM.

Представьте будущее, в котором плашки «игрового ОЗУ» имеют на борту RP2040 для автоматизации эксплойта (и, разумеется, управляют RGB-светодиодами).

Есть похожая история про устройства Android и проверки SafetyNet/Play Integrity, хотя поместить в телефон модчип для глитчинга будет гораздо сложнее.

Размышления


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

Взаимодействие со структурами, поддерживающими иллюзию виртуальной памяти, кажется мне похожим на побег из «Матрицы».

Вопросы без ответов


  • Сработает ли это с DDR4, DDR5? (Не вижу причин, почему бы не сработало!)
  • Сработает ли это с ARM? (Аналогично)
  • В какой степени различные типы ECC противодействуют этому? (И в особенности DDR5 Link-ECC).
  • Как проще всего вызвать похожие неисправности электронным образом? (Допустим, при помощи RP2040).
  • Можно ли использовать это, чтобы выйти за пределы гипервизора?
  • Можно ли на основе этого написать эксплойт Webkit?
  • Можно ли на основе этого написать эксплойт ядра Nintendo Switch?

Я буду изучать эти вопросы в будущем, следите за новостями!

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

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

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


  1. baldr
    09.10.2024 13:15
    +14

    Можно ли получить рут при помощи одной зажигалки?

    Самый очевидный способ почему-то не рассмотрен.


    1. aelaa
      09.10.2024 13:15

      Там зажигалки мало, нужен паяльник.
      Впрочем тут тоже...


    1. densss2
      09.10.2024 13:15
      +1

      А с двумя зажигалками пароль можно добыть ещё быстрее!)))


  1. zoto_ff
    09.10.2024 13:15

    вот кому-то делать нечего


  1. qwertypromes
    09.10.2024 13:15

    До конца надеялся что рут будут получать на андроид


  1. 15432
    09.10.2024 13:15

    Посмотрите как геохот делал гличинг RAM на PS3. Как раз похоже - там замыканием линии RAM достигалась ошибка записи при удалении мапинга, из-за чего мапинг оставался и из userspace можно было залезть в гипервизор