Windows: достучаться до железа

В чём суть, капитан?

В архитектуре x86 есть понятие «колец защиты» («Ring») – режимов работы процессора. Чем ниже номер текущего режима, тем больше возможностей доступно исполняемому коду. Самым ограниченным «кольцом» является «Ring 3», самым привилегированным – «Ring -2» (режим SMM). Исторически сложилось, что все пользовательские программы работают в режиме «Ring 3», а ядро ОС – в «Ring 0»:

Режимы работы x86 процессора

В «Ring 3» программам запрещены потенциально опасные действия, такие как доступ к I/O портам и физической памяти. По логике разработчиков, настолько низкоуровневый доступ обычным программам не нужен. Доступ к этим возможностям имеют только операционная система и её компоненты (службы и драйверы). И всё бы ничего, но однажды я наткнулся на программу RW Everything:

RW Everything действительно читает и пишет практически всё

Эта программа была буквально напичкана именно теми функциями, которые обычно запрещаются программам «Ring 3» — полный доступ к физической памяти, I/O портам, конфигурационному пространству PCI (и многое другое). Естественно, мне стало интересно, как это работает. И выяснилось, что RW Everything устанавливает в систему прокси-драйвер:

Смотрим последний установленный драйвер через OSR Driver Loader

Прокси-драйвера

В итоге получается обходной манёвр – всё, что программе запрещено делать, разработчик вынес в драйвер, программа устанавливает драйвер в систему и уже через него программа делает, что хочет! Более того – выяснилось, что RW Everything далеко не единственная программа, которая так делает. Таких программ не просто много, они буквально повсюду. У меня возникло ощущение, что каждый уважающий себя производитель железа имеет подобный драйвер:

  • Софт для обновления BIOS (Asrock, Gigabyte, HP, Dell, AMI, Intel, Insyde…)
  • Софт для разгона и конфигурации железа (AMD, Intel, ASUS, ASRock, Gigabyte)
  • Софт для просмотра сведений о железе (CPU-Z, GPU-Z, AIDA64)
  • Софт для обновления PCI устройств (Nvidia, Asmedia)

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

Результаты краткого анализа пары десятков драйверов. Могут быть ошибки!

Небольшая легенда:

  • Mem – чтение / запись физической памяти
  • PCI – чтение / запись PCI Configuration Space
  • I/O – чтение / запись портов I/O
  • Alloc – аллокация и освобождение физической памяти
  • Map – прямая трансляция физического адреса в вирутальный
  • MSR – чтение / запись x86 MSR (Model Specific Register)

Жёлтым обозначены возможности, которых явно нет, но их можно использовать через другие (чтение или маппинг памяти). Мой фаворит из этого списка – AsrDrv101 от ASRock. Он устроен наиболее просто и обладает просто огромным списком возможностей, включая даже функцию поиска шаблона по физической памяти (!!)

Неполный перечень возможностей AsrDrv101

Самое нехорошее в такой ситуации — если подобный драйвер остаётся запущенным на ПК пользователя, для обращения к нему не нужно даже прав администратора! То есть любая программа с правами пользователя сможет читать и писать физическую память — хоть пароли красть, хоть ядро пропатчить. Именно на это уже ругались другие исследователи. Представьте, что висящая в фоне софтина, красиво моргающая светодиодиками на матплате, открывает доступ ко всей вашей системе. Или вирусы намеренно ставят подобный драйвер, чтобы закрепиться в системе. Впрочем, любой мощный инструмент можно в нехороших целях использовать.

Через Python в дебри

Конечно же я захотел сделать свой небольшой «тулкит» для различных исследований и экспериментов на базе такого драйвера. Причём на Python, мне уж очень нравится, как просто выглядит реализация сложных вещей на этом языке.

Первым делом нужно установить драйвер в систему и запустить его. Делаем «как положено» и сначала кладём драйвер (нужной разрядности!) в System32:

#puts the driver into Windows/System32/drivers folder
def SaveDriverFile(self):
  winPath = os.environ['WINDIR']
  sys32Path = os.path.join(winPath, "System32")
  targetPath = os.path.join(sys32Path, "drivers\\" + self.name + ".sys")
  file_data = open(self.file_path, "rb").read()
  open(targetPath, "wb").write(file_data)

Раньше в похожих ситуациях я извращался с папкой %WINDIR%\Sysnative, но почему-то на моей текущей системе такого алиаса не оказалось, хотя Python 32-битный. (по идее, на 64-битных системах обращения 32-битных программ к папке System32 перенаправляются в папку SysWOW64, и чтобы положить файлик именно в System32, нужно обращаться по имени Sysnative).

Затем регистрируем драйвер в системе и запускаем его:

#registers the driver for further startup
def RegisterDriver(self):
  serviceManager = win32service.OpenSCManager(None, None, 
                                              win32service.SC_MANAGER_ALL_ACCESS)
  driverPath = os.path.join(os.environ['WINDIR'], 'system32\\drivers\\' + 
                            self.name + '.sys')
  serviceHandle = win32service.CreateService(serviceManager,self.name,self.name,
                                             win32service.SERVICE_ALL_ACCESS, 
                                             win32service.SERVICE_KERNEL_DRIVER, 
                                             win32service.SERVICE_DEMAND_START, 
                                             win32service.SERVICE_ERROR_NORMAL,
                                             driverPath, None,0,None,None,None)
  win32service.CloseServiceHandle(serviceManager)
  win32service.CloseServiceHandle(serviceHandle)

#starts the driver
def RunDriver(self):
  win32serviceutil.StartService(self.name)

А дальше запущенный драйвер создаёт виртуальный файл (кстати, та самая колонка «имя» в таблице с анализом дров), через запросы к которому и осуществляются дальнейшие действия:

И ещё одна полезная программа для ползания по системе, WinObj

Тоже ничего особенного, открываем файл и делаем ему IoCtl:

#tries to open the driver by name
def OpenDriver(self):
    handle = win32file.CreateFile("\\\\.\\" + self.name, 
                                  win32file.FILE_SHARE_READ | 
                                  win32file.FILE_SHARE_WRITE, 
                                  0, None, win32file.OPEN_EXISTING, 
                                  win32file.FILE_ATTRIBUTE_NORMAL | 
                                  win32file.FILE_FLAG_OVERLAPPED, 
                                  None)
    if handle == win32file.INVALID_HANDLE_VALUE:
          return None
    return handle

#performs IOCTL!
def IoCtl(self, ioctlCode, inData, outLen=0x1100):
    out_buf = win32file.DeviceIoControl(self.dh,ioctlCode,inData,outLen,None)
    return out_buf

Вот здесь чутка подробнее. Я долго думал, как же обеспечить адекватную обработку ситуации, когда таких «скриптов» запущено несколько. Не останавливать драйвер при выходе нехорошо, в идеале нужно смотреть, не использует ли драйвер кто-то ещё и останавливать его только если наш скрипт «последний». Долгие упорные попытки получить количество открытых ссылок на виртуальный файл драйвера ни к чему не привели (я получал только количество ссылок в рамках своего процесса). Причём сама система точно умеет это делать — при остановке драйвера с открытым файлом, он остаётся висеть в «Pending Stop». Если у кого есть идеи — буду благодарен.

В конечном итоге я «подсмотрел», как это делают другие программы. Выяснилось, что большинство либо не заморачиваются, либо просто ищут запущенные процессы с тем же именем. Но одна из исследованных программ имела кардинально другой подход, который я себе и перенял. Вместо того, чтобы переживать по количеству ссылок на файл, просто на каждый запрос открываем и закрываем файл! А если файла нет, значит кто-то остановил драйвер и пытаемся его перезапустить:

#perform IOCTL!
def IoCtl(self, ioctlCode, inData, outLen=0x1100):
  #open driver file link
  driverHandle = self.OpenDriver()
  if driverHandle is None:
    self.ReinstallDriver()
    driverHandle = self.OpenDriver()
    #second try
    if driverHandle is None:
      return None
  #perform IOCTL
  out_buf = win32file.DeviceIoControl(driverHandle,ioctlCode,inData,outLen,None)
  #close driver file link
  win32file.CloseHandle(driverHandle)
  return out_buf

А дальше просто реверсим драйвер и реализуем все нужные нам вызовы:

class PmxInterface:
  def __init__(self):
    self.d = PmxDriver("AsrDrv101")

    def MemRead(self, address, size, access=U8):
      buf = ctypes.c_buffer(size)
      request = struct.pack("<QIIQ", address, size, access, 
                            ctypes.addressof(buf))
      if self.d.IoCtl(0x222808, request, len(request)):
        return bytearray(buf)
      else:
        return None

      def MemWrite(self, address, data, access=U8):
        buf = ctypes.c_buffer(data, len(data))
        request = struct.pack("<QIIQ", address, len(data), access, 
                              ctypes.addressof(buf))
        return self.d.IoCtl(0x22280C, request, len(request)) is not None
      # (и все остальные тоже)

И вуаля:

Легко и непринуждённо в пару команд читаем физическую память

PCI Express Config Space

Немного отвлечёмся на один нюанс про PCIE Config Space. С этим адресным пространством не всё так просто — со времён шины PCI для доступа к её конфигурационному пространству используется метод с использованием I/O портов 0xCF8 / 0xCFC. Он применён и в нашем драйвере AsrDrv101:

Чтение и запись PCI Config Space

Но через этот метод доступны только 0x100 байт конфигурационного пространства, в то время как в стандарте PCI Express размер Config Space у устройств может быть достигать 0x1000 байт! И полноценно вычитать их можно только обращением к PCI Extended Config Space, которая замаплена где-то в адресном пространстве, обычно чуть пониже BIOS:

Адресное пространство современного x86 компа, 0-4 ГБ

На чипсетах Intel (ну, в их большинстве) указатель на эту область адресного пространства можно взять из конфига PCI устройства 0:0:0 по смещению 0x60, подробнее описано в даташитах:

У AMD я такого не нашёл (наверняка есть, плохо искал), но сам факт неуниверсальности пнул меня в сторону поиска другого решения. Погуглив стандарты, я обнаружил, что указатель на эту область передаётся системе через ACPI таблицу MCFG

А сами ACPI таблицы можно найти через запись RSDP, поискав её сигнатуру по адресам 0xE0000-0xFFFFF, а затем распарсив табличку RSDT. Отлично, здесь нам и пригодится функционал поиска по памяти. Получаем нечто такое:

rsdp = self.PhysSearch(0xE0000, 0x20000, b"RSD PTR ", step=0x10)
#use rsdt only for simplicity
rsdt = self.MemRead32(rsdp + 0x10)
(rsdtSign, rsdtLen) = struct.unpack("<II", self.MemRead(rsdt, 8, U32))
if rsdtSign == 0x54445352: #RSDT
  headerSize = 0x24
  rsdtData = self.MemRead(rsdt + headerSize, rsdtLen - headerSize, U32)
  #iterate through all ACPI tables
  for i in range(len(rsdtData) // 4):
    pa = struct.unpack("<I", rsdtData[i*4:(i+1)*4])[0]
    table = self.MemRead(pa, 0x40, U32)
    if table[0:4] == b"MCFG":
      #we have found the right table, parse it
      (self.pciMmAddress, pciSeg, botBus, self.pciMmTopBus) = 
       struct.unpack("<QHBB", table[0x2C:0x38])

На всякий случай оставляем вариант для чипсетов Intel

if self.PciRead16(PciAddress(0,0,0,0)) == 0x8086:
  #try intel way
  pciexbar = self.PciRead64(PciAddress(0,0,0,0x60))
  if pciexbar & 1:
    self.pciMmTopBus = (1 << (8 - ((pciexbar >> 1) & 3))) - 1
    self.pciMmAddress = pciexbar & 0xFFFF0000

Всё, теперь осталось при необходимости заменить чтение PCI Express Config Space через драйвер на чтение через память. Теперь-то разгуляемся!

Читаем BIOS

В качестве примера применения нашего «тулкита», попробуем набросать скрипт чтения BIOS. Он должен быть «замаплен» где-то в конце 32-битного адресного пространства, потому что компьютер начинает его исполнение с адреса 0xFFFFFFF0. Обычно в ПК стоит флеш-память объёмом 4-16 МБ, поэтому будем «сканировать» адресное пространство с адреса 0xFF000000, как только найдём что-нибудь непустое, будем считать, что тут начался BIOS:

from PyPmx import PmxInterface
pmx = PmxInterface()

for i in range(0xFF000000, 0x100000000, 0x10000):
  data = pmx.MemRead(i, 0x20)
  if data != b"\xFF"*0x20 and data != b"\x00"*0x20:
    biosLen = 0x100000000-i
    print("BIOS Found at 0x%x" % i)
    f = open("dump.bin", "wb")
    for j in range(0, biosLen, 0x1000):
      data = pmx.MemRead(i + j, 0x1000)
      f.write(data)
      break

В результате получаем:

Вот так в 10 строчек мы считали BIOS

Но подождите-ка, получилось всего 6 мегабайт, а должно быть 4 или 8 что-то не сходится. А вот так, у чипсетов Intel в адресное пространство мапится не вся флешка BIOS, а только один её регион. И чтобы считать всё остальное, нужно уже использовать SPI интерфейс.

Не беда, лезем в даташит, выясняем, что SPI интерфейс висит на PCI Express:

И для его использования, нужно взаимодействовать с регистрами в BAR0 MMIO по алгоритму:

  1. Задать адрес для чтения в BIOS_FADDR
  2. Задать параметры команды в BIOS_HSFTS_CTL
  3. Прочитать данные из BIOS_FDATA

Пилим новый скрипт для чтения через чипсет:

from PyPmx import PmxInterface, PciAddress, U32

spi = PciAddress(0, 31, 5)
pmx = PmxInterface()
spiMmio = pmx.PciRead32(spi + 0x10) & 0xFFFFF000
f = open("dump.bin", "wb")

for i in range(0, 0x800000, 0x40):
  # write BIOS_FADDR
  pmx.MemWrite32(spiMmio + 0x08, i)
  # write BIOS_HSFTS_CTL
  #        read      0x40 bytes      start     clear fcerr & fgo
  cmd = (0 << 17) | (0x3F << 24) | (1 << 16) |         3
  pmx.MemWrite32(spiMmio + 0x04, cmd)
  # wait for read or error
  curCmd = pmx.MemRead32(spiMmio + 0x04)
  while curCmd & 3 == 0:
    curCmd = pmx.MemRead32(spiMmio + 0x04)
  # read BIOS_FDATA
  data = pmx.MemRead(spiMmio + 0x10, 0x40, U32)
  f.write(data)

Исполняем и вуаля — в 20 строчек кода считаны все 8 МБ флешки BIOS! (нюанс — в зависимости от настроек, регион ME может быть недоступен для чтения).

Точно так же можно делать всё, что заблагорассудится — делать снифер USB пакетов, посылать произвольные ATA команды диску, повышать частоту процессора и переключать видеокарты. И это всё — с обычными правами администратора:

Немного помучившись, получаем ответ от SSD на команду идентификации

А если написать свой драйвер?

Некоторые из вас наверняка уже подумали — зачем так изворачиваться, реверсить чужие драйвера, если можно написать свой? И я о таком думал. Более того, есть Open-Source проект chipsec, в котором подобный драйвер уже разработан.

Зайдя на страницу с кодом драйвера, вы сразу наткнетесь на предупреждение:

WARNING

В этом предупреждении как раз и описываются все опасности, о которых я рассказывал в начале статьи — инструмент мощный и опасный, следует использовать только в Windows режиме Test Mode, и ни в коем случае не подписывать. Да, без специальной подписи на обычной системе просто так запустить драйвер не получится. Поэтому в примере выше мы и использовали заранее подписанный драйвер от ASRock.

Если кто сильно захочет подписать собственный драйвер — понадобится регистрировать собственную компанию и платить Microsoft. Насколько я нагуглил, физическим лицам такое развлечение недоступно.

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

У меня под рукой нет Windows DDK, так что я взял 64-битный vfd.sys, скомпилированный неким critical0, и попросил dartraiden подписать его «древне-китайским способом». Такой драйвер успешно загружается и работает, если vfdwin запущена с правами администратора

Драйвер из статьи действительно подписан, и действительно неким китайским ключом:

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

Немного поиска этого имени в гугле, и я натыкаюсь на вот эту ссылку, откуда узнаю, что:

  • есть давно утёкшие и отозванные ключи для подписи драйверов
  • если ими подписать драйвер — он прекрасно принимается системой
  • малварщики по всему миру используют это для создания вирусни

Основная загвоздка — заставить майкрософтский SignTool подписать драйвер истёкшим ключом, но для этого даже нашёлся проект на GitHub. Более того, я нашёл даже проект на GitHub для другой утилиты подписи драйверов от TrustAsia, с помощью которого можно подставить для подписи вообще любую дату.

Несколько минут мучений с гугл-переводчиком на телефоне, и мне удалось разобраться в этой утилите и подписать драйвер одним из утекших ключей (который довольно легко отыскался в китайском поисковике):

И в самом деле, китайская азбука

И точно так же, как и AsrDrv101, драйвер удалось без проблем запустить!

А вот и наш драйвер запустился

Из чего делаю вывод, что старая идея с написанием своего драйвера вполне себе годная. Как раз не хватает функции маппинга памяти. Но да ладно, оставлю как TODO.

Выводы?

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

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *