Разбираемся с Integer Overflow: Переполнение целого числа в libxml2

В повседневной жизни числа кажутся бесконечными. Но внутри нашего устройства числа хранятся в контейнерах фиксированного размера, и когда в результате вычисления получается значение, которое слишком велико или слишком мало для своего контейнера, мы получаем integer overflow или integer underflow
Именно здесь и возникают integer overflow и integer underflow.
Эти проблемы — не просто курьезы, они могут вызывать логические ошибки, сбои и даже уязвимости в системе безопасности. Давайте разберемся в этом шаг за шагом.
0. Знаковые и беззнаковые целые числа
В программировании есть два основных типа целых чисел: signed (со знаком) и unsigned (без знака).
- Signed integer (со знаком) может представлять как положительные, так и отрицательные числа (включая ноль). На большинстве систем 32-битный
int
имеет диапазон от−2,147,483,648
до+2,147,483,647
.
Если посмотреть в шестнадцатеричном представлении, то граница у знаковых чисел проходит между:
0x7fffffff
(максимальное положительное: 2,147,483,647)- и
0x80000000
(минимальное отрицательное: −2,147,483,648)
После достижения максимума значение «переворачивается» в отрицательное и снова растёт вверх, пока не вернётся к −1
.
1. Знаковые целые числа (Signed Integers)
Как уже сказано, знаковые целые могут хранить и положительные, и отрицательные значения.
Для 32-битного int
диапазон равен: −2,147,483,648 … 2,147,483,647
.
1.1 Переполнение знаковых чисел (Signed Integer Overflow)
Переполнение происходит, когда мы превышаем максимальное положительное значение.
Вывод:
Как мы видим, произошел переполнение целого числа с знаком. Значение «перескочило» с максимального на минимальное отрицательное.
1.2 Обратное переполнение (Signed Integer Underflow)
Обратное переполнение возникает, когда мы уходим ниже минимального отрицательного значения.
Вывод:
Здесь значение минимального отрицательного числа «прыгает» в максимально положительное.
2. Беззнаковые целые числа (Unsigned Integers)
Беззнаковые числа хранят только ноль и положительные значения.
Для 32-битного unsigned int
диапазон: 0 … 4,294,967,295
.
2.1 Переполнение беззнаковых (Unsigned Overflow)
Когда мы превышаем максимум, значение возвращается к нулю.
Вывод:
2.2 Обратное переполнение беззнаковых (Unsigned Underflow)
Если вычесть единицу из нуля, значение перепрыгнет к максимуму.
Вывод:
✨ Итог
- Signed Integer переполняется из положительных в отрицательные и наоборот.
- Unsigned Integer переполняется по кругу: от максимума к нулю и от нуля к максимуму.
В реальных библиотеках (например, libxml2) переполнения индексов могут приводить к выходу за границы массива (Out-Of-Bounds) и уязвимостям, позволяющим атакующему повредить память программы.
libxml2: Переполнение целого числа, приводящее к переполнению кучи в xmlRegEpxFromParse
Переполнение буфера в куче существует в функции xmlRegEpxFromParse
внутри xmlregexp.c. Уязвимость возникает из-за переполнения целого числа при вычислении индекса в таблице переходов во время компиляции регулярного выражения, используемого для валидации DTD.
Как работает xmlRegEpxFromParse
Когда libxml2 обрабатывает XML-документ с DTD, содержащим описание содержимого (content model), внутри создаётся автомат (state machine). Этот автомат затем компилируется в регулярное выражение функцией xmlRegEpxFromParse
.
Чтобы хранить переходы состояний автомата, функция выделяет массив transitions
[1]. Далее она проходит по всем состояниям и переходам и заполняет таблицу. Индекс внутри массива вычисляется так:index = stateno * (nbatoms + 1) + atomno + 1
Где:
stateno
— номер (перенумерованный индекс) текущего состояния автомата.nbatoms
— количество уникальных элементов (атомов) в модели содержимого.atomno
— номер текущего атома (элемента), по которому строится переход.
Что идёт не так
Все три переменные (stateno
, nbatoms
, atomno
) — это знаковые 32-битные целые числа (int).
Если автомат достаточно большой, произведение stateno * (nbatoms + 1)
может превысить максимально допустимое значение 2,147,483,647
. Тогда возникает переполнение целого числа: результат «оборачивается» в отрицательное значение.
Этот отрицательный индекс используется для обращения к массиву transitions
. В итоге:
-
на строке [2] (
xmlregexp.c
) происходит чтение из неверного места в памяти, -
на строке [3] выполняется запись по тому же неверному адресу.
Результат — повреждение кучи (heap corruption).
Пример данных (PoC)
Что здесь происходит:
nbatoms = 46341
Это количество уникальных имён элементов (a0 … a46340
).stateno
— Соответствует состояниям автомата. Например:0 = старт (ожидаем a0) 1 = после a0 (ожидаем a1) 2 = после a1 (ожидаем a2) ...
atomno
— Это индекс конкретного элемента (атома), по которому происходит переход. Например:atomno 0 → a0 atomno 1 → a1 atomno 2 → a2 ...
Ключевой момент:
46341 * (46341 + 1) + 1 = 0x8000c71f
Это значение уже выходит за пределы допустимого диапазона 32-битного int
. Именно тут и происходит integer overflow.
Почему проверка выделения памяти не спасает?
Функция xmlRegCalloc2
[1] действительно содержит проверки на переполнение, но они касаются только размера выделяемой памяти. Внутри используются типы size_t
(64-битные на современных системах).
Однако индексирование в xmlRegEpxFromParse
ведётся через 32-битные int
, и именно там случается переполнение. То есть:
- библиотека может успешно выделить блок памяти более 2 ГБ,
- но расчёт индекса «сломается» из-за переполнения 32-битного
int
, - и мы получаем выход за границы массива (out-of-bounds).