Загрузка...
Author's article Exploiting of binary vulnerabilities
  1. Палки
    Палки Topic starter May 27, 2025 make testers great again!
    Привет, дорогие друзья :hi:

    Данная статья посвящена теме бинарных уязвимостей, описывает некоторые базовые понятия, а так-же различные техники эксплуатации и поиск таких уязвимостей.
    Приятного чтения!
    Стек (c английского: stack, в контексте нашей статьи) - это структура, которая хранит различные данные, будь то значения регистров, адреса возврата и прочее.

    Подчиняется эта структура правилу LIFO (Last In First Out), что дословно переводится как "Последний зашёл - первым выйдет", пример:
    1. Мы засунули в стек число 0xA (первый зашёл)
    2. Затем мы поместили туда число 0xF (последний зашёл)
    После чего мы обратились к стеку, чтобы вытащить оттуда значение, и получили 0xF (последний зашёл => первым вышел).

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

    Регистр ESP хранит адрес вершины стека.
    Регистр EBP хранит базовый адрес текущего кадра стека (то есть точку, по отрицательному или положительному смещению от которой мы попадаем на локальные переменные, адреса возврата, аргументы функций и тд).

    Наглядная демонстрация (адрес => значение):
    // выше всех идут локальные переменные, это следует запомнить
    [ESP] или [EBP-0x8] => 0xF < последнее число в стеке
    [ESP+0x4] или [EBP-0x4] => 0xA <- первое число в стеке
    [ESP+0x8] или [EBP] => SAVED EBP <- базовый адрес текущего кадра стека
    [ESP+0x12] или [EBP+0x4] => RETURN <- адрес возврата
    // тут аргументы функции (если бы они были) - ARG 1, ARG 2
    // допустим [EBP+0x8] хранит число 0x100 - это первый аргумент функции, то есть ARG 1

    То есть, данные помещаются в стек как-бы сверху вниз (из-за адресации, от меньшего адреса к большему):
    [IMG]
    Регистры - это участки памяти непосредственно на самом процессоре, которые могут хранить 8, 16, 32, и 64 битные числа.

    Небольшая оговорка.
    В архитектуре x86 основными являются 32 битные регистры, EAX, EBX, ECX, EDX, ESP, EBP и тд.
    В архитектуре x64 помимо упомянутых, так-же существуют 64 битные регистры (здесь основными будут уже они), их отличие в том, что называются они RAX, RBX, RCX (по аналогии с 32 битными, только первая буква R) и хранят по 64 бита каждый.

    Следующие регистры в архитектуре х64 будут взаимосвязаны:
    EAX <-> RAX
    EBX <-> RBX
    ECX <-> RCX

    При изменении значения регистра EAX, будут изменены 32 бита (половина) значения регистра RAX:
    [IMG]
    Опкоды - это инструкции для процессора, благодаря которым он понимает, какую операцию необходимо совершить - то есть числа.

    Процессор буквально работает с байтами (машинным кодом), поэтому для удобства были придуманы инструкции - мнемонические слова, сопоставляемые с опкодами.

    пример:
    инструкция nop означает "ничего не делать", её опкод 0x90.

    инструкция add берёт первый операнд, и прибавляет к нему второй операнд, пример: add eax, 15.

    инструкция pop берёт значение из стека, и помещает в указанный регистр, пример: pop eax.

    инструкция push помещает значение указанного регистра в стек, пример: push eax.

    инструкция ret выполняет переход на адрес возврата.
    Соглашение о вызове - это способ передачи параметров в функцию.

    Простая функция в ассемблере выглядит примерно так:
    pop eax
    add eax, 10
    push eax
    ret

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

    Пройдёмся по инструкциям:
    - инструкция pop eax поместит значение со стека в регистр eax.
    - инструкция add eax, 10 увеличит значение в регистре eax на 10.
    - инструкция push eax запишет значение регистра eax в стек.
    - инструкция ret совершит переход на адрес возврата, т.е туда, откуда была вызвана эта функция (на следующую после вызова инструкцию).

    Таким образом, если мы передадим в стек число 20, после выполнения функции в стеке будет лежать число 30.

    Описанный мною способ передачи параметров через стек называется cdecl (происходит от c-declaration), это одно из соглашений о вызове.

    thiscall (справедлив для C++) - подразумевает, что первый аргумент функции это указатель на текущий экземпляр (объект) класса, пример:
    cpp

    void animal::print_age() {
    std::cout << this->age << std::endl;
    }

    на самом деле ООП не существует, и под капотом вы увидите обычные функции без классов, нечто вроде:
    cpp

    void $animal@print_age$26278(uintptr_t this) {
    // где 0x10 - это offsetof(animal, name), то есть смещение от начала объекта (структура animal) до адреса, с которого начинается определенное поле, в нашем случае name
    std::cout << *(uint32_t*)(this+0x10) << std::endl;
    }
    в данном случае this это простая числовая переменная, которая хранит адрес объекта класса animal, то есть начало данной структуры.

    если сместиться на 0x10, мы получим адрес поля age.
    Глобальная таблица смещений (Global Offset Table) - это секция (область памяти) внутри программы, которая хранит адреса динамически прилинкованных функций.
    Динамическая линковка нужна для экономии места и избежания дублирования кода внутри программы (по сравнению со статической линковкой).
    Сложная для многих тема, но постараюсь объяснить простым языком.

    Каждая переменная занимает место в памяти:
    char - 1 байт // хранит значение от -127 до 128, либо от 0 до 255, если unsigned (беззнаковое число, отсчёт начинается с нуля)
    short - 2 байта // от -32768 до 32767 (возможно любители игры Minecraft узнали это число), либо от 0 до 65535, если unsigned
    int, long, float - 4 байта // от -2.147.483.648 до 2.147.483.647
    size_t - 4 или 8 байт (зависит от разрядности системы)
    long long, long float, double - 8 байт // для очень больших чисел

    Создание переменных в C++ выглядит так:
    cpp

    int x = 14; // целое число x со значением 14
    float PI = 3.14f; // дробное число PI со значением 3.14
    Так-же у каждой переменной есть свой адрес - это место в памяти, где она находится.
    Обратиться к адресу любой переменной можно при помощи оператора &, то есть:
    std::cout << &x << std::endl; // в консоль будет выведен адрес переменной x.

    А создание указателей в C++ выглядит следующим образом:
    cpp

    int* x_ptr = &x; // указатель x_ptr указывает на тип данных int (4 байта в памяти), и хранит адрес переменной x.
    Стоит заметить, что раз указатель хранит адрес, он является таким-же целым числом, и следовательно - занимает какое-то место в памяти (адреса не могут храниться в воздухе).
    Для этого существуют типы данных size_t, uintptr_t, intptr_t и тд.

    На 32 битных системах указатели занимают 4 байта (потому что максимальное кол-во ОЗУ - 8 гбайт, и банально не нужно много места, чтобы записать число - адрес в памяти, т.к она сильно ограничена):
    sizeof(void*), sizeof(int*) и sizeof(size_t) будет равно sizeof(uintptr_t) и тд, все они будут равны 4 байтам.

    На 64 битных системах указатели занимают по 8 байт соответственно (ибо лимит по памяти там гораздо больше, и чтобы обратиться к "далёкой" ячейке, например если у вас 64 гбайт ОЗУ, мы просто не сможем уместить число в 4 байта, банально не хватит размера указателя):
    sizeof(void*), sizeof(int*), sizeof(size_t) и ему подобных типов данных будет равно 8 байтам
    Арифметика указателей это особые операции, которые можно производить с указателями.
    p.s Указатели - численные типы данных, значение которых - какой-то адрес, если по адресу 0x100 лежит переменная int x = 15, то указатель int* y = (int*)0x100 (или int* y = &x) будет указывать на x, следовательно обращаясь к указателю оператором разыменования (*) мы получим значение переменной x, то есть: *y == x (будет true).

    Во первых, указатели можно складывать друг с другом.
    Во вторых, к указателям можно применять операторы --, ++, складывать с ними другие числа (отнимать, прибавлять единицу).
    пример:
    Если мы имеем дело с динамическим массивом (а это тоже указатель), мы можем использовать сложение словно индекс элемента в массиве:
    cpp

    int len = 6;
    int* arr = malloc(sizeof(int) * len);
    // проходимся по элементам
    for (int i = 0; i < len; i++) {
    //arr + 0 (подобно просто arr) указывает на первый элемент массива
    // arr + 1 - на второй элемент массива
    // и так далее (пусть 0, 1 и тд будет индексом).
    // для более глубокого понимания, вот так выглядит альтернативная запис (мы как бы сдвигаемся на "индекс умножить на размер элемента"):
    // *(arr + X) => *(int*)((size_t)arr + 1 * sizeof(arr[0]))
    // что в целом будет работать примерно одинаково, и даст такой же результат.

    // вывод текущего элемента в консоль
    std::cout << *(arr + i) << std::endl;
    }
    Fastbin - это стратегия управления памятью в glibc malloc (вид чанков), обычно используемая для того, чтобы выделять небольшие области в памяти, чаще всего размером от 16 до 128 байт.

    Использование fastbin в целом положительно влияет на производительность программы из-за оптимизаций, но при этом допускает атаки, которые можно провернуть с ним (подробнее об этом будет ниже).

    Largebin - это стратегия управления памятью в glibc malloc (вид чанков), соответственно используемая для больших (large) чанков, которые по размеру превышают 512 кбайт.
    О переполнении буфера
    ROP - Return Oriented Programming - возвратно ориентированное программирование, это одна из техник, позволяющая выполнять код путём перезаписи адреса возврата на стеке.
    ROP цепочка состоит из ROP гаджетов.

    ROP гаджеты состоят из набора опкодов, которые завершаются опкодом 0xC3 (инструкция ret).
    Картинка из интернета.

    На ней изображён ROP гаджет, находящийся по адресу 0x000ED7CB, который помещает значение регистра EBX в регистр EAX, затем значение из стека помещается в регистр EBX, после чего следующее значение из стека помещается в регистр ESI.

    При написании эксплоитов стоит учитывать инструкции POP, после адреса возврата в стеке должен быть любой мусор, которым мы забьём эти регистры.

    В противном случае, в регистры попадут адреса ROP гаджетов, и наша ROP цепочка отработает не так, как планировалось.
    [IMG]

    Различные ROP гаджеты и их сочетание позволяют делать самые разные вещи, начиная от установки конкретных значений в регистры (через mov и константу, или через push и стек), заканчивая вызовом любых функций.

    Допустим, где-то в недрах уязвимого приложения мы нашли функцию, которая заканчивается так:
    asm

    // пусть инструкция mov будет расположена по адресу 0x100
    mov eax, 1h
    xor ecx, ecx
    ret

    0x100 - это адрес ROP гаджета, который поместит в регистр eax единицу, в ecx - ноль (xor сам на себя обнулит значение регистру), и выполнит переход на следующий адрес возврата.

    Если мы перезапишем текущий адрес возврата в стеке числом 0x100, то после выполнения уязвимой функции, нас перебросит на указанный адрес - выполнение будет продолжаться, начиная с адреса 0x100.

    Далее, в регистр eax будет записано число 1, после чего значение регистра ecx будет обнулено, и мы попадём туда, откуда пришли (как описывал выше), то есть ROP гаджет отработал, выполнение идёт дальше.

    Адрес возврата можно перезаписать несколько раз "подряд", в таком случае адреса должны следовать друг за другом.
    Пример (как это выглядит в стеке):
    asm

    0x100 // первый адрес
    0x200 // за ним следует второй адрес, сюда мы попадём после выполнения инструкции ret предыдущего ROP гаджета
    0x300 // следующие адреса
    ROP гаджеты можно искать руками, а можно использовать специальные утилиты.
    Две основные из них (примеры использования):
    ⁡ropgadget --binary yourApp

    ⁡ropper --file yourApp


    Эти команды выведут огромный список из ROP гаджетов, рекомендую сразу перенаправлять вывод в файл, для упрощения последующей работы с ними.
    Я рекомендую использовать обе утилиты вместе, т.к бывали случаи, когда одна из них не могла обнаружить часть гаджетов, которые успешно были найдены другой.
    Рассмотрим несколько техник, которые помогут приблизиться к выполнению кода.
    Чтобы записать строки в память, для начала стоит узнать адрес, куда мы можем записать наше значение.
    Обнаружив пустую область в программе, записываем её адрес (пусть будет 0x2000 для наглядности), он нам понадобится.

    Далее наша задача - найти ROP гаджет, которые позволит контролировать значения некоторых регистров, допустим, 0x12345:
    asm

    pop ecx // берём со стека значение и записываем в ecx
    pop edx // берём со стека значение и записываем в edx
    ret

    Находим гаджет, который позволит записать данные по указанному нами адресу, пусть это будет, 0x54321:
    asm

    mov dword ptr [edx], ecx // берёт значение из ecx и записывает по адресу edx)
    ret

    Допустим, мы хотим записать в память строку "/bin/sh" (привет любителям линукса).
    Любую строку можно представить в виде чисел, деля её на строки по 4 символа в каждой:
    "/bin" - 4 байта, которые можно представить в виде числа, пусть это будет число XXXX.
    "//sh" - здесь я добавил ещё один символ /, чтобы заполнить недостающий четвёртый байт в числе (на путь в строке это никак не повлияет), пусть это будет число YYYY.

    Записываем в стек адрес возврата, затем значение для ECX, затем значение для EDX, и следующие адреса из ROP цепочки:
    0x12345 // первый гаджет, задаём значения регистрам
    XXXX // значение регистра ECX, т.е первая часть строки
    0x2000 // значение регистра EDX, т.е адрес
    0x54321 // второй гаджет, записываем строку в память
    0x12345 // первый гаджет, задаём значения для второй половины строки
    YYYY // значение ECX, вторая часть строки
    0x2004 // значение EDX, адрес
    0x54321 // второй гаджет, дозаписываем вторую часть строки


    После выполнения данной ROP цепочки мы увидим в памяти нечто подобное:
    0x2000 "/"
    0x2001 "b"
    0x2002 "i"
    0x2003 "n"
    0x2004 "/"
    0x2005 "/"
    0x2006 "s"
    0x2007 "h"
    Вызывать функции мы будем на примере линукса.
    В линуксе есть замечательная функция execve, которая идеально подходит для раскручивания нашей уязвимости и последующего выполнения кода.

    Плюс этой функции заключается в том, что вызвать её можно через syscall (системный вызов), то есть достаточно просто указать номер функции (для execve это 11) в регистр EAX, затем передать адрес строки ("/bin//sh") в регистр EBX (то есть значение 0x2000, если ориентироваться на прошлый спойлер), после чего заполнить регистры ECX и EDX нулями (это остальные параметры функции. нули - потому что их мы не используем, argv, envp).

    Номера функций для системного вызова можно посмотреть в заголовочном файле asm/unistd_32.h, если кому интересно), они представлены в виде макросов.

    Подготовив параметры для вызова функции, можно вызвать её.
    Делается это при помощи прерывания:
    int 80h

    После выполнения данной инструкции будет вызвана оболочка bash, чего более чем достаточно для того, чтобы доказать факт наличия уязвимости максимально наглядно (Proof-of-Concept).
    Переполнение стека (с английского: Stack Overflow) - Явление, при котором процессор записывает данные туда - куда не нужно (по вине разработчика программы), это вызывает неопределённое поведение в программе, что может привести к зависанию, вылету программы или даже к выполнению кода, контролируемого хакером.

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

    Пример уязвимого кода:
    cpp

    int main(int argc, char* argv[]) {
    const char* str = "xxxxxxxx";

    char buf[4];
    // здесь произойдёт переполнение буфера buf
    strcpy(buf, str);
    }

    Главные задачи при эксплуатации переполнения стека - установить нужное смещение (забить стек и дойти до адреса возврата), найти необходимые для нашего эксплоита ROP гаджеты, составить валидную ROP цепочку и проэксплуатировать уязвимость при помощи составленного пейлоада.
    Такая уязвимость возникает, когда мы переполняем кучу, т.е область с динамически выделенными данными, например через malloc.
    В целом, эксплуатация чем-то напоминает переполнение на стеке:
    - выбраться за пределы буфера;
    - перезаписать критичные данные;
    - изменить ход работы программы.

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

    Про эксплуатацию переполнения буфера на стеке написано ближе к концу статьи.
    Поиск и выявление уязвимостей в ПО
    Patch diffing - метод сравнения двух бинарных файлов разных версий (до и после исправления уязвимости), используется для изучения того, как именно была исправлена уязвимость, и последующего получения информации о том, где она находилась.
    Такой подход позволяет исследовать конкретную часть уязвимой версии ПО, чтобы разработать эксплоит и пытаться "доить" тех, кто по какой-то причине забыл обновить ПО до актуальной версии).
    Довольно популярная техника для написания 1day эксплоитов.
    Для наглядности я взял уязвимость CVE-2025-29967.
    Это уязвимость в протоколе RDP (от Microsoft), подробнее напишу ниже.
    Официальный бюллетень Microsoft гласит следующее:
    bash

    тип уязвимости: CWE-122 Heap-based Buffer overflow
    уровень опасности: критический
    импакт: Remote Code Execution
    Remote Code Execution - это довольно серьёзный вид уязвимости - Удалённое выполнение кода.
    думаю, название говорит само за себя.

    листнём чуть ниже (раздел FAQ), дабы понять, как она эксплуатируется:
    How could an attacker exploit this vulnerability?
    In the case of a Remote Desktop connection, an attacker with control of a Remote Desktop Server could trigger a remote code execution (RCE) on the RDP client machine when a victim connects to the attackers server with the vulnerable Remote Desktop Client.

    понимаем, что уязвима именно клиентская сторона.
    простым языком:
    уязвимый RDP клиент (компьютер Васи) подключается к вредоносному серверу, что позволяет выполнить код на стороне клиента.
    следовательно, для её эксплуатации нам придётся писать скрипт, имитирующий работу RDP сервера.

    листаем ещё чуть ниже (раздел Security Updates), и видим следующие уязвимые продукты:
    Windows 11 24H2 сборки с версией до 10.0.26100.4061 / 10.0.26100.3981
    Windows 1123H2 сборки с версией до 10.0.22631.5335
    Windows 10 22H2 сборки с версией до 10.0.19045.5854
    Windows 10 21H2 сборки с версией до 10.0.19044.5854
    Remote Desktop client for Windows Desktop версии до 1.2.6228.0
    и тд.

    помимо различных версий Windows, моё внимание привлёк пункт с RDP клиентом:
    Remote Desktop client for Windows Desktop версии до 1.2.6228.0.

    в колонке Download есть ссылка на обновление безопасности.
    открываем её и в пункте Version history and release notes (список версий ПО) видим следующее:
    [IMG]
    из бюллетеня мы вычитали, что патч с исправленной уязвимостью вышел 13 мая 2025 года и имеет версию 1.2.6228.
    следовательно, версия до него (на скриншоте 1.2.6227, вышедшая 22 апреля 2025 года) будет уязвимой.
    на этой же странице приложена ссылка на версию с патчем (1.2.6228):
    https://res.cdn.office.net/remote-desktop-windows-client/ff46c1a3-9b23-4752-a79c-d13a309ca9d3/RemoteDesktop_1.2.6228.0_x64.msi

    скачиваем её (ссылка на virustotal)
    Здесь пришлось применить методы OSINT (разведка по открытым источникам).

    Обратим внимание на название файла, который мы имеем:
    ⁡RemoteDesktop_1.2.6228.0_x64.msi

    Попробуем поменять версию на другую, теоретически уязвимая версия должна называться так:
    ⁡RemoteDesktop_1.2.6227.0_x64.msi


    Теперь гуглим то что получилось, и получаем следующие результаты:
    [IMG]
    На нескольких сайтах есть checksum файла (уникальный хеш), идём на virustotal, чтобы узнать побольше информации о нём (подставляем в ссылку на virustotal этот хеш):
    https://www.virustotal.com/gui/file/9c8cf57dba0725c594c6ee15cf9670ea507612a3e1036fc584f5eb365d8acf34

    Здесь видим, что файл с таким названием действительно существует, а его размер - 32.87 мегабайт, первое сканирование произошло 23 апреля (2025-04-23 19:02:36 UTC), что совпадает с датой релиза данной версии.

    Далее, на другом сайте из списка я обнаружил ссылку на скачивание уязвимой версии:
    [IMG]
    Нас интересует версия x64 (не смотря на то, что 32 битные бинари более просты для анализа).
    скачиваем её по прямой ссылке:
    https://res.cdn.office.net/remote-d...4fc7729a7ee7/RemoteDesktop_1.2.6227.0_x64.msi

    Домен совпадает с тем, откуда мы устанавливали версию 6228, поэтому беспокоиться не о чем, на всякий случай снова прикладываю ссылку на virustotal.
    Хеш данного файла совпал с хешом, который был ранее обнаружен выше.
    И так, на данный момент мы имеем два MSI файла:
    RemoteDesktop_1.2.6228.0_x64.msi - после патча
    RemoteDesktop_1.2.6227.0_x64.msi - до патча

    MSI - это своего рода архив, который содержит само ПО, определённые ресурсы, значения регистров Windows и тд.
    Точное определение из гугла:
    MSI-файл представляет собой базу данных, в которой хранятся все необходимые данные для процесса установки, такие как файлы, ярлыки, записи в реестре и т. д.

    Для адекватного анализа стоит разобрать его, и вытащить оттуда непосредственно бинарники.
    Копируем .msi файлы в общую папку, у меня это:
    C:\Users\New\Desktop\CVE-2025-29967

    Создаём внутри ещё две папки:
    C:\Users\New\Desktop\CVE-2025-29967\1.2.6227
    C:\Users\New\Desktop\CVE-2025-29967\1.2.6228
    В них мы сохраним результат распаковки .msi файлов (разных версий соответственно).

    Манипуляции с MSI файлами позволяет проводить команда msiexec.
    Открываем терминал, вводим msiexec --help и получаем помощь по её использованию:
    Установщик Windows. Версия XXXXX

    msiexec /Option <обязательный параметр> [необязательный параметр]

    Параметры установки
    </package | /i> <Product.msi>
    Установка или настройка продукта
    /a <Product.msi>
    Административная установка - установка продукта в сеть
    /j<u|m> <Product.msi> [/t <список преобразований>] [/g <код языка>]
    Объявление о продукте: "m" - всем пользователям; "u" - текущему пользователю
    </uninstall | /x> <Product.msi | Код_продукта>
    Удаление продукта
    Параметры отображения
    /quiet
    Тихий режим, без взаимодействия с пользователем
    /passive
    Автоматический режим - только указатель хода выполнения
    /q[n|b|r|f]
    Выбор уровня интерфейса пользователя
    n - Без интерфейса
    b - Основной интерфейс
    r - Сокращенный интерфейс
    f - Полный интерфейс (по умолчанию)
    /help
    Вывод справки по использованию
    Параметры перезапуска
    /norestart
    Не перезапускать после завершения установки
    /promptrestart
    Запрашивать перезапуск при необходимости
    /forcerestart
    Всегда перезапускать компьютер после завершения установки
    Параметры ведения журнала
    /l[i|w|e|a|r|u|c|m|o|p|v|x|+|!|*] <файл_журнала>
    i - сообщения о состоянии
    w - сообщения об устранимых ошибках
    e - все сообщения об ошибках
    a - запуски действий
    r - записи, специфические для действий
    u - запросы пользователя
    c - начальные параметры интерфейса пользователя
    m - сведения о выходе из-за недостатка памяти или неустранимой ошибки
    o - сообщения о недостатке места на диске
    p - свойства терминала
    v - подробный вывод
    x - дополнительные отладочные сведения
    + - добавление в существующий файл журнала
    ! - сбрасывание каждой строки в журнал
    * - заносить в журнал все сведения, кроме параметров "v" и "x"
    /log <файл_журнала>
    Равнозначен /l* <файл_журнала>
    Параметры обновления
    /update <Update1.msp>[;Update2.msp]
    Применение обновлений
    /uninstall <Код_Guid_обновления>[;Update2.msp] /package <Product.msi | код_продукта>
    Удаление обновлений продукта
    Параметры восстановления
    /f[p|e|c|m|s|o|d|a|u|v] <Product.msi | код_продукта>
    Восстановление продукта
    p - только при отсутствии файла
    o - если файл отсутствует или установлена старая версия (по умолчанию)
    e - если файл отсутствует или установлена такая же либо старая версия
    d - если файл отсутствует или установлена другая версия
    c - если файл отсутствует или контрольная сумма не совпадает с подсчитанным значением
    a - принудительная переустановка всех файлов
    u - все необходимые элементы реестра, специфические для пользователя (по умолчанию)
    m - все необходимые элементы реестра, специфические для компьютера (по умолчанию)
    s - все существующие ярлыки (по умолчанию)
    v - запуск из источника с повторным кэшированием локальных пакетов
    Настройка общих свойств
    [PROPERTY=PropertyValue]

    Обратитесь к пакету SDK установщика Windows за дополнительными
    сведениями по использованию командной строки.

    Корпорация Майкрософт. Все права защищены.
    В некоторых частях программы использованы разработки Independent JPEG Group.
    Следующая команда распакует файл path/to/file.msi в папку path/to/folder:
    ⁡msiexec /a path/to/file.msi /qb TARGETDIR=path/to/folder


    Подставим в неё пути к двум бинарям, и выполним полученные команды (ваши пути могут отличаться):
    ⁡msiexec /a C:\Users\New\Desktop\CVE-2025-29967\RemoteDesktop_1.2.6227.0_x64.msi /qb TARGETDIR=C:\Users\New\Desktop\CVE-2025-29967\1.2.6227

    ⁡msiexec /a C:\Users\New\Desktop\CVE-2025-29967\RemoteDesktop_1.2.6228.0_x64.msi /qb TARGETDIR=C:\Users\New\Desktop\CVE-2025-29967\1.2.6228


    После чего в каждом из путей появится по одному файлу и одной папке:
    TARGETDIR/имя_файла.msi - тут наш файл msi
    TARGETDIR/Remote Desktop/ - тут вложено очень много файлов

    [IMG]
    Для анализа полученных файлов я буду использовать дизассемблер ghidra - разработанный Агенством Национальной Безопасности США, этот продукт совершенно точно не содержит никаких бекдоров и заплаток, поэтому его можно смело скачать из их официального репозитория:
    https://github.com/NationalSecurityAgency/ghidra

    Создаём новый проект и импортируем туда файлы, для начала - msrdc_1.2.6227.exe и msrdc_1.2.6228.exe.
    Создать проект - File -> New Project (W)
    Импортировать файлы - File -> Import File (I)
    Для анализа разных версий одного и того же файла, в ghidra предусмотрен инструмент Version Tracking.
    Принцип его работы заключается в сессиях, каждая сессия - это разница между двумя файлами (то есть три файла или более - не поддерживается, создавайте несколько отдельных сессий).

    Открываем окно Version Tracking.
    После чего кликаем на New Session, чтобы создать новую сессию.
    Назовём её именем экзешника (msrdc.exe) чтобы проще было ориентироваться по ним, после чего выбираем два файла:
    src - версия до фикса (1.2.6227), dest - версия после фикса (1.2.6228).
    [IMG]
    Кликаем Next, затем кликаем Run Precondition Checks, и снова Next, затем Finish.

    На данном этапе ghidra предложит вам проанализировать файлы, если вы ещё этого не делали ранее.
    Соглашаемся, после чего открывается два-три окна, и начнётся анализ файлов.
    [IMG]

    На данном этапе находим окно "Version Tracking: msrdc.exe", после чего кликаем на волшебную палочку, и начнётся волшебство.
    Процесс анализа:
    [IMG]

    После окончания анализа:
    [IMG]
    В этом же окне можно посмотреть, какие функции были изменены, и что конкретно в них поменялось:
    [IMG]

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

    Подумаешь, всего лишь 165 тысяч результатов нашлось. Отсеим их!
    Score - значение, которое показывает, насколько схожи участки кода, варьируется от -1 (в нашем случае от 0) до единицы, где 1 означает идеальное совпадение (100%).

    в поле ввода Score Filter вводим диапазон от 0.99 до 0.999, чтобы исключить несовпавшие ни с чем участки кода (score 0) и неизменённые функции (score 1), а так-же диапазон от 0.001 до 0.99 (к нему мы вернёмся чуть позже).
    мы получим 104 совпадения по функциям.

    как показывает практика, это самые мизерные совпадения, такие как на скриншоте ниже.
    здесь практически не меняется код функции, или её сигнатура, это большая редкость.
    [IMG]
    их можно смело игнорировать, так-же как и score 0 вместе с 1.

    в диапазоне Score от 0 до 0.001 мы получим в районе 3042 совпадений - это участки кода/функции, которые не совпали ни с одной существующей функцией - т.е были добавлены с нуля, или полностью вырезаны. они нас НЕ интересуют.

    в диапазоне Score от 1.0 до 1.0 мы получим в районе 161.856 совпадений - это функции, существующие и в прошлой, и в новой версии, которые вообще никак не изменились. они нас так-же НЕ интересуют.

    в целом, диапазона от 0.8 до 0.99 (595 совпадений) достаточно, чтобы выделить функции, которые были явно изменены.
    Во время анализа, мы увидим лишь два значения статуса:
    1 - флажок, означающий что всё корректно распознано, и проблем не возникло.
    2 - замок, который означает, что где-то гидра дала сбой.

    Вот пример, у каких функций может быть замок.
    1. Функция A, внутри которой код в духе:
    int A(int x) {
    if (x == 10) return 5;
    return x;
    }
    2. Функция B, внутри которой код очень похож на A:
    int B(int x) {
    if (x == 4) return 5;
    return x;
    }

    Не смотря на сходство этих двух функций, они обе есть и в первом, и во втором файле.
    Только гидра (во время сигнатурного анализа) может случайно посчитать их за изменения, допустим:
    Изменение 1 - в функции A число 10 было изменено на 4 (думает, что B это A)
    Изменение 2 - в функции B число 4 было изменено на 10 (думает, что A это B)

    Когда как на самом деле, никаких изменений не происходило.
    Реальный пример, на который я сам наткнулся:
    Первое совпадение, гидра посчитала FUN_140076360 в версии 1.2.6227 за функцию FUN_140076300 в версии 1.2.228:
    [IMG]

    Второе совпадение, где гидра посчитала FUN_140076300 в версии 1.2.6227 за функцию FUN_140076360 в версии 1.2.6228:
    [IMG]
    Для того, чтобы икслючить эти функции, необходимо отфильтровать список по колонке Status.
    Далее мы проходимся по совпадением где отображается замок, и сравниваем их.

    Если видим подобную картину - смело избавляемся от совпадения, кликая по нему ПКМ и выбирая пункт Remove Match:
    [IMG]

    Так-же можно смело удалять те совпадения, код в которых похож на вызов библиотечных функций Visual Studio (C++), т.к никакого отношения к логике самого приложения они не имеют:
    [IMG]
    Если мы видим, что в прошлой версии ПО есть какие-то проверки (блоки if, или условный прыжок в ассемблере), которые были удалены в новой версии, то скорее всего можно смело удалять такое совпадение.
    Потому что heap-buffer overflow - это про отсутствие важной проверки (в нашем случае на длину буфера), а не про её вырезание)
    [IMG]
    Итого, после Score фильтра у нас осталось 595 совпадений.

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

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

    UPD:
    Позже на личном опыте выяснилось, что стоковые корреляторы ghidra совершенно непригодны для patch diffing анализа, поэтому я рекомендую использовать плагин с открытым исходным кодом PatchDiffCorrelator, который добавляет дополнительные, более точные корреляторы.

    Далее, опытным путём отсеивая различные функции, рано или поздно нам удастся выйти на место, где ранее была уязвимость.
    Особенности эксплуатации бинарных уязвимостей
    Для успешной эксплуатации heap-based buffer overflow на линуксе можно воспользоваться уже известными техниками (эксплуатируется преимущественно glibc):
    p.s Чанками для удобства называются области в памяти на куче, куда указывают наши указатели, такое название происходит от структуры malloc_chunk

    Чтобы разобраться, как работают некоторые из этих трюков, для начала посмотрим на структуру чанков в куче (glibc heap structure), т.е. на то, как они лежат в памяти:
    cpp

    struct malloc_chunk {
    INTERNAL_SIZE_T prev_size; // предыдущий размер чанка, если он стал свободен (freed)
    INTERNAL_SIZE_T size; // размер текущего чанка в байтах (включая overhead)
    // двойная ссылка - используется только в освобождённых чанках
    struct malloc_chunk* fd; // forward
    struct malloc_chunk* bk; // back
    // эти поля используются только для больших чанков
    struct malloc_chunk* fd_nextsize;
    struct malloc_chunk* bk_nextsize;
    };
    Любой адрес, который возвращает malloc, по умолчанию указывает на поле fd.
    Таким образом:
    p2 => *fd (указатель p2 указывает на поле fd)
    (p2-1) => size (указатель p2 по отрицательному смещению будет указывать на поле перед fd, то есть на size)

    Почему мы отнимаем единицу, а не кол-во байт, которое занимает поле? потому что указатель p2 хранит адрес и под капотом имеет тип size_t / uintptr_t (называйте как угодно), следовательно придерживается арифметики указателей.
    Подробнее про указатели и их арифметику вы можете почитать выше (спойлер "Основы").

    Эксплуатируем malloc, заставляя его вернуть нам указатель на уже выделенную область в куче при помощи fastbin free list.

    cpp

    // этот код демонстрирует атаку двойного освобождения (double-free) fastbin чанков

    // инициализируем 8 fastbin чанков
    void* ptrs[8];
    for (int i = 0; i < 8; i++) {
    ptrs[i] = malloc(8);
    }

    // аллоцируем 3 буфера аналогичной длины через calloc:
    int* a = calloc(1, 8);
    int* b = calloc(1, 8);
    int* c = calloc(1, 8);

    // освобождаем первый из них:
    free(a);

    // если мы снова освободим первый буфер, это вызовет вылет программы,
    // потому что адрес a находится на вершине списка освобождённых чанков (free list)
    // поэтому давайте попробуем освободить b:
    free(b);

    // мы можем освободить fastbin чанк a снова,
    // т.к теперь он не находится на вершине списка free list (её изначальное место занял b)
    free(a);

    // теперь free list выглядит следующим образом:
    // [ a (new), b (new), a (old) ]
    // направление head => tail

    // если мы трижды вызовем аллокацию, мы получим адрес буфера a второй раз:
    a = calloc(1, 8);
    b = calloc(1, 8);
    c = calloc(1, 8);

    // третий вызов calloc вернул адрес буфера a
    printf("magic: c = %p, a = %p", c, a);
    Освобождаем несуществующий (ложный) чанк fastbin, чтобы заставить malloc вернуть нам указатель на произвольную область в памяти.
    Данный трюк был продемонстрирован на CTF 2014 OREO)

    cpp

    // массив из 7 чанков (указатели)
    void* chunks[7];

    // ШАГ 1:
    // инициализируем 7 fastbin чанков (исходя из их размера)
    for (int i = 0; i < 7; i++) {
    chunks[i] = malloc(0x30);
    }
    // сразу же освобождаем их
    for (int i = 0; i < 7; i++) {
    free(chunks[i]);
    }

    // ШАГ 2:
    // подготавливаем фейковые чанки
    // не смотрите на число 10 - fake_chunks это просто область в памяти
    // для последующих аллокаций (которые будут фактически являться двумя fastbin чанками из-за выравнивания)
    long fake_chunks[10] __attribute__ ((aligned (0x10)));
    printf("fake chunk located at %p", fake_chunks);

    // из-за смещений (struct allign), этот массив располагается на двух чанках по адресам:
    printf("first chunk at %p, second chunk at %p", &fake_chunks[1], &fake_chunks[9]);

    // chunk.size для первого чанка должен на 16 байт превышать размер данных чанка
    // чтобы вместить данные структуры malloc_chunk, но при этом
    // соответствовать критериям fastbin чанка (занимать <= 128 байт, актуально для 64 битной системы).
    // бит PREV_INUSE (первый младший бит) игнорируется функцией free для
    // чанков, аллоцированных по принципу fastbin,
    // но с битами IS_MMAPPED (второй младший бит) и
    // NON_MAIN_ARENA (третий младший бит) возникнет проблемка.

    // обратите внимание, что chunk.size должен совпадать с размером следующей аллокации malloc
    // (т.е аргумент функции malloc при следующем её вызове должен быть равен chunk.size),
    // округлённый / выровненный до внутреннего размера, используемого в реализации malloc (речь идёт о struct malloc_chunk)
    // то есть в 64 битной системе значения 0x30-0x38 будут округлены до 0x40
    // поэтому это число вполне подходит нам

    // теперь установим размер фейкового чанка равным 0x40.
    // таким образом, аллокатор glibc malloc будет думать, что
    // имеет дело с валидным, нормальным чанком
    fake_chunks[1] = 0x40; // это поле struct malloc_chunk::size для первого чанка в памяти

    // размер chunk.size следующего фейкового чанка (который начинается с &fake_chunks[9])
    // так-же должен соответствовать норме:
    // - быть больше чем 2 * SIZE_SZ (т.е. больше 16 в 64 битных системах)
    // - быть меньше чем av->system_mem (по умолчанию это 128 кб)
    // для того, чтобы пройти проверки целостности nextsize (поле struct malloc_chunk::fd_nextsize)

    // === ВНИМАНИЕ ===
    // если не соблюсти вышеперечисленные условия, аллокатор просто вызовет abort(), и все старания окажутся напрасными

    // теперь устанавливаем значение второго чанка на 0x1234
    // чтобы освобождение первого чанка прошло успешно:
    fake_chunks[9] = 0x1234;

    // ШАГ 3:
    // освобождаем первый чанк
    // прошу заметить, что адрес первого чанка
    // должен быть выровнен по 16 байтам
    void* victim = &fake_chunks[2];
    free(victim);

    // ШАГ 4:
    // теперь вызов calloc должен вернуть нам указатель на фейковый чанк &fake_chunks[2]
    // malloc тоже способен это сделать, но придётся вызвать его по меньшей мере 8 раз, поэтому мы пойдём более простым путем
    void* allocated = calloc(1, 0x30);

    // а теперь смотрим что вернул calloc - профит!
    printf("allocated at %p, fake chunk: %p", allocated, victim);
    Эксплуатируем перезапись поля size у освобождённого чанка, чтобы следующий чанк был аллоцирован на месте одного из предыдущих, тем самым позволяя перезаписывать данные прошлых чанков, обращаясь к новому.
    cpp

    #include <stdlib.h>
    #include <string.h>
    #include <stdint.h>

    int main(int argc , char* argv[]) {
    // создаём 4 указателя на 4 чанка соответственно, пока они пусты
    intptr_t *p1,*p2,*p3,*p4;

    // выделяем память под первые 3 чанка, они попадут в кучу (heap)
    // для удобства можно вывести их в stdout и посмотреть, где именно они были аллоцированы
    p1 = malloc(0x500 - 8);
    p2 = malloc(0x500 - 8);
    p3 = malloc(0x80 - 8);

    // забиваем их произвольными данными, в нашем случае для наглядности используются цифры 1, 2 и 3
    memset(p1, '1', 0x500 - 8);
    memset(p2, '2', 0x500 - 8);
    memset(p3, '3', 0x80 - 8);

    // освобождаем второй чанк
    // на этом моменте начинается веселье - p2 указывает на освобождённый unsorted bin чанк (поле malloc_chunk::fd)
    // т.к мы освободили память, теперь туда может быть записано что угодно
    // при этом мы всё ещё в состоянии перезаписать поле malloc_chunk::size для достижения этого трюка (см. ниже)
    free(p2);

    // обратите внимание:
    // фактический размер "злого" чанка это размеры p2 + p3, но без учёта malloc_chunk::size (для дальнейшей перезаписи данных в p3)
    int evil_chunk_size = 0x581;
    // цифра 8 здесь означает sizeof(size_t), размер поля malloc_chunk::size, для 32 битных систем здесь логично будет 4
    int evil_region_size = 0x580 - 8;

    // на этом моменте происходит самое интересное - мы записываем последние биты освобождённого чанка, отвечающие за длину чанка
    // после данного трюка, размер чанка будет установлен в 0x581 байт
    // адрес p2 - 1 указывает на поле размера чанка, malloc_chunk::size
    *(p2-1) = evil_chunk_size;

    // аллоцируем следующий чанк
    // этот вызов malloc вернёт адрес области в памяти, принадлежавшей предыдущему освобождённому чанку (p2)
    p4 = malloc(evil_region_size);

    // теперь, копируя что угодно в p4, будет перезаписан кусок данных в p3

    // перезаписываем чанки чтобы в этом убедиться:
    memset(p4, '4', evil_region_size);
    memset(p3, '3', 80);
    return 0;
    }

    А теперь разберёмся, почему это работает.
    Возьмём следующий код:
    cpp

    char *A, *B, *C; // три буфера
    // выделяем первый чанк
    // здесь произойдёт переполнение
    A = malloc(0x100 - 8);

    // выделяем второй чанк
    // чуть ниже мы его "освободим"
    B = malloc(0x100 - 8);

    // выделяем третий чанк
    // после реалокации произойдёт наложение чанка B на чанк C
    C = malloc(0x80 - 8);

    // теперь посмотрим, куда указывает указатель C (и где он заканчивается):
    printf("C begin: %p, C end: %p", C, C + 0x80 - 8);

    // освобождаем второй чанк
    // после этого структура malloc_chunk будет считаться освобождённой (статус freed)
    free(B);

    // теперь перезаписываем первый чанк (выходим за его пределы)
    // после этого трюка второй чанк будет занимать 0x181 байт (вместо 0x101)
    // потому что следом за A идёт начало чанка B:
    // struct malloc_chunk {
    // INTERNAL_SIZE_T prev_size; // размер предыдущего чанка, если он свободен
    // следовательно, перезаписав это значение, мы сломаем логику поведения malloc
    A[0x100 - 8] = 0x81;

    // реаллоцируем чанк B
    // обратите внимание на размер - он гораздо больше, чем был (на 0x80 байт)
    // и стал равен прошлому размеру B + размеру C
    B = malloc(0x100 + 0x80 - 8);

    // теперь смотрим, какую область занимает чанк B, и удивляемся:

    В памяти это будет выглядеть примерно следующим образом (картинка из интернета):
    [IMG]

    Таким образом, мы перезаписали размер чанка B при помощи выхода за границы чанка A, после чего реаллоцировав чанк B, он залез на соседний чанк C.

    UPD:
    Статья была полностью переписана и объединена с другими частями в единое целое, было затронуто большое количество сложных тем.
    Надеюсь, она станет отличной отправной точкой в изучении бинарных уязвимостей, реверс инженеринга и эксплойтинга, а если вы имели такой опыт прежде - поможет вам узнать для себя что-то новое)
     
    This article was useful for you?
    You can thank the author of the topic by transferring funds to your balance
    Thank the author

Comments

    1. Самомнение
      Самомнение May 27, 2025 Рекламный креативщик - https://lolz.live/threads/6238236 6666 Oct 23, 2018
      То что нужно, буду ебланов в кс крашить
       
      1. View previous comments (4)
      2. Пингвиниак
        gonome, у билла гейтса от такого хуй к потолку встанет
    2. kipsad
      kipsad May 27, 2025 Купить домен анонимно - t.me/FastDomainBot 17,512 Dec 24, 2018
      топ 1 пример
      топ 5 статей
      топ 1 непонимание, че к чему, я тупой для такого еще, сорянчик
       
    3. exepert
      exepert May 27, 2025 Оплачу :hamster: вашу покупку 5261 Nov 25, 2018
      однозначно годная инфа
       
    4. Кастория
      Кастория May 27, 2025 Буст вашего аккаунта HSR - https://lolz.live/threads/8984483/ 3698 Oct 9, 2020
      Палки в cs2 есть чит, и у него была такая функция что он крашил тебе игру когда на тебя выходит)
       
    5. CodeRed
      CodeRed May 27, 2025 368 Jan 4, 2019
      еще когда ксго была, что-то похожее было, только еще можно было посылать на сервак много пакетов и он начинал лагать у всех. Отдельные луашки и скрипты были с крашером для читов. Помню фризил сервак, у всех все лагало и они бомбили угарно.
      Мб кто знает, есть ли такие скрипты из-за которых сервак начинает именно лагать, а не крашится
       
      1. Палки Topic starter
        CodeRed, да, банально выявляются пакеты которые сильнее всего влияют на стабильность сервера (долго обрабатываются), затем происходит мини DDoS такими пакетами
    6. tonwarden
      tonwarden May 28, 2025 :smile_read: Лучшие ****** здесь - lolz.live/threads/8856953/ 2061 Apr 27, 2025
      Грамотно пишешь, читать приятно. Аллегории с Васей прям в точку. Я вспомнил про баг, когда можно было порно в меню голосования в кс2 поставить. Годно :pepeNewYear:
       
      1. Палки Topic starter
        tonwarden, это была уязвимость HTML Injection, никнеймы отображались не как текст, а как HTML разметка)
        по моему уязвимую библиотеку оттуда потом вырезали, если не ошибаюсь.
      2. tonwarden
        Палки, хаахахаха, нормально
    7. DaleCooper
      Классная статья! Скажем так, вектор развития для изучения данного направления задан :smine_nice:
       
    8. drwhooper
      drwhooper May 29, 2025 Banned 0 Jan 5, 2025
      Для чего используются регистры RAX и RDX ? в игре контер страйк
       
      1. Палки Topic starter
        drwhooper, конкретно в игре - не используются.
        они нужны для функций и передачи параметров
    9. Smex
      Smex May 29, 2025 смешно. ха... ха.... ха...... 606 Sep 15, 2020
      классная статья, а будет слив 0day уязвимостей по active directory? мне так чисто для школьного реферата надо, информатик сказал что ток такие принимает
       
      1. Палки Topic starter
        Smex, может быть)
      2. andercrew
        Smex, лучше уж rce в esxi, или хайперв эскейп
    10. pamef
      pamef Jun 2, 2025 4187 May 24, 2021
      помню в майнкрафте обнаружили уязвимость, через которую можно было запускать код на чужом компьютере.
      достаточно было просто ввести нужное сообщение в чат
       
      1. Палки Topic starter
        pamef, уязвимость была в библиотеке log4j, клиент игры (да и сервер) при логировании сообщений движка (в том числе чата), подгружал вредоносный класс из пейлоада)
    11. Cybernoob
      Cybernoob Jun 21, 2025 18 May 10, 2022
      Зашёл только из-за Сенко :senko:
       
    12. doublepurpose
      doublepurpose Jun 25, 2025 117 Mar 27, 2025
      Не дурно но странно, особенно "базовые понятия", в которых описана основа работы функций компилируемых ЯП, соглашения вызовов, как будто слишком жирно для одной статьи с названием "основы" В целом, для человека +- понимающего это будет как памятка, для учащегося - лучше начинать с базы и основ, но так чтобы более концентрироваться на них что-ли, на низком уровне оч легко путаться особенно если перешел с более абстрактного и лаконичного высокого(а это 99% челов в реверсе), думаю стоит сидеть на годболте и смотреть во что компилируется твой С-код(это увлекательно, особенно с ООП каким нибудь). Касаемо самого пейлоада и уязвимости, тема значительно меньше раскрыта в сравнении даже с базовыми понятиями, но наверное найдется тот кому эта часть даже больше зашла. Имхо прежде чем лезть реверсить сеть игрульки(или даже думать об этом), пытаться строить шеллкод и прокидывать его, будто бы лучше пойти и подшарить за базу инженерки, причем до приличного уровня. Хотя с другой стороны для лолза думаю это прям оч хорошо в этой тематике :finger_up:
       
      1. Палки Topic starter
        doublepurpose, сложновато всё в одну статью упихнуть, и разжевать чтобы больше людей могли понять её
      2. doublepurpose
        Палки, Верно, как по мне стоит сделать ряд статей постепенных, правда не знаю будет ли обратка с этого тебе и есть ли желание у тебя делать настолько подробно все. По крайней мере я бы точно с удовольствием почитал обновив свои скромные познания в этой сфере
      3. Палки Topic starter
        doublepurpose, желание всегда есть, не всегда хватает времени
    13. geek
      geek Jul 16, 2025 0 Jul 13, 2025
      Its strange that such a 'hole' ist closed
       
    14. Genxus
      Genxus Jul 19, 2025 https://lolz.live/threads/8945240/ <- поиск поставщика симок 1269 Feb 11, 2024
      я небинарный
       
    15. princ32
      princ32 Jul 20, 2025 Батя съебался в восемь лет, мама любила дочку 82 Jan 24, 2018
      Тот кто не шарит - ничего не поймет. Тот кто шарит и так все понимает. Для кого статья - хз
       
      1. Палки Topic starter
        princ32, задавайте уточняющие вопросы, я готов дать ответ
      2. princ32
        Палки, бро, я могу твою статью сравнить с попыткой объяснить библию, только в одном стихотворении. Но все равно спасибо за старания)
    16. Здравствуйте
      1. Палки Topic starter
        Здравствуйте, если что-то кажется сложным - значит мозг развивается и прогресс не стоит на месте)
    17. kibersanya2003
      вот ты кстати рил ахуенно все описал, красава
       
    18. t1mka
      t1mka Jul 20, 2025 пиздатая разработка https://lolz.live/threads/4950640/ 1726 Aug 11, 2022
      контраст авторских статей конечно поражает
       
    19. HashNCode
      HashNCode Jul 20, 2025 Печатает... 19,677 Mar 2, 2024
      Полезная история спасибо!
       
Loading...
Top