-
Автор темы
- #1
Внимание: Впечатление от статьи может испортить качество текстового оформления на югейме. Статья в оригинале доступна на
Вступление
Всем привет! Давно не было никакого материала от меня, хоть обещаний было много… Ну… как вам сказать…
Ладно, сейчас не об этом. Ручная деобфускация строк это довольно нудный и монотонный процесс, и заниматься этим когда ваш пик концентрации достигает 3 минуты невозможно… Может быть, вы также собираете мусор в своей квартире, который копится неделями? Ну и я об этом, поэтому сегодня мы за 15 минут напишем свой скрипт, который решит эту сложную проблему.
Почему в названии 15 секунд, а на деле 15 минут? Потому что конечное решение позволит вам деобфусцировать строки в функции за 15 секунд.
В нашем арсенале сегодня будет два бинарных файла:
Fatality
Пример №1
Рассмотрим первый случай шифрование строк
Если быть честным, то нас тут интересует всего три инструкции:
Давайте рассмотрим их более детально:
В декомпилированном виде это выглядит так:
Пример №2
Выглядит громоздко, и страшно?
Я с вами полностью согласен, но если разбирать небольшими частями, то в результате это всё тот-же
Рассмотрим детальнее
В декомпилированном виде это выглядит так:
Составляем шаблон
Написание шаблона
Чтобы найти все экземпляры шифрования строк нужно составить шаблон. Мы будем составлять шаблон, опираясь всего на две инструкции, потому что мы слишком ленивы, чтобы делать его более точным.
1: 0F 28 86 80 00 00 00 | movaps xmm0, xmmword ptr [esi+80h]
2: 0F 57 02 | xorps xmm0, xmmword ptr [edx]
Разбираем эти инструкции по частям:
Составлять шаблон будем вида текста в шестнадцатеричном формате ( IDA Style ).
Теперь мы можем составить шаблон для поиска всех экземпляров шифрования строк:
0F 28 ? ? ? ? ? 0F 57
Этот шаблон будет искать все вхождения, где первая инструкция - movaps xmm0, а вторая инструкция - xorps xmm0.
Теперь можно использовать этот шаблон для поиска всех мест в коде, где происходит дешифрование строк.
Тестирование шаблона
Поскольку мы слишком ленивы, чтобы написать собственное решение для тестирования шаблона, мы воспользуемся IDA Pro и их поиском по последовательности байтов.
Целых 1274! Но стоит отметить, что наш паттерн не точный, и мы нашли еще часть простой математики никак не связанной с дешифрованием строк.
Начинаем писать полу-эмулятор
Для нашего ленивого деобфускатора мы будем использовать Python, ведь его биндинги имеет абсолютно каждая библиотека.
Обсуждения работы
Первая версия нашего деобфускатора будет работать в пределах одной функции, и полностью игнорировать control flow.
Принцип работы:
Зарождение надежды
Мы начинаем с чистого листа — пустого файла, ожидающего первых строк логики. Этот чистый холст представляет собой генезис нашего творения, перед которым открывается безграничный потенциал.
Вспомогательные функции
Итак, начнём! Первым делом нам нужна функция которая будет загружать наш бинарный файл
Для сканирования бинарного файла на шаблоны сначала нам нужно преобразовать наш текст в шестнадцатеричном формате в массив байтов.
Теперь сама функция сканирования бинарного файла на шаблон:
Дополнительно нам нужна функция которая пройдётся дизассемблером по всему бинарному файлу, и сохранит это всё в словарь, чтобы в будущем посчитать размер нужных нам инструкций
Проверим?
Работает! Наш сканер шаблонов не потерял ни одного экземпляра, и сравнился с IDA Pro.
Подготовка к эмуляции
Есть одна проблема. Сканер находит начало инструкции movaps после совпадения с нашим шаблоном. Однако для эмуляции нам необходимо, чтобы адрес был началом инструкции после xorps.
Чтобы найти правильный адрес:
В итоге после этих шагов мы получаем конечный адрес эмуляции.
Ох, мы совсем забыли инициализировать наш эмулятор, это вы виноваты!
Эмулируем
Для начала нам нужно написать промежуточный хук, чтобы проверять какую инструкцию эмулятор собирается исполнять. В общем процесс выглядит примерно так:
Нам нужен еще один хук, чтобы эмуляция не выдавала ошибку, если натыкалась на не аллоцированную память в куче.
Теперь конечная цель это проэмулировать нашу функцию от начала до нужной нам инструкции, и извлечь результаты с xmm0.
В конечном итоге наша главная функция будет выглядеть так:
Заключение
Тестируем наш скрипт
Давайте выполним наш скрипт на небольшую функцию, и проверим его работоспособность.
Да! Он действительно работает, хоть и не обрабатывает все случаи. Базовые возможности его улучшения описаны в статье, но это всего лишь базовые! Всё ограничивается вашей фантазией.
Конец?
В этой статье мы рассмотрели метод автоматического поиска и эмуляции дешифрования строк с помощью Python. Несмотря на то, что этот метод является базовым, он демонстрирует, как сочетание статического анализа и динамической эмуляции может позволить дальше отдыхать на диване, вместо того, чтобы тратить десятки минут на ручное дешифрование строк.
До следующей встречи, через 152 дня.
Мой шизоблог:
Спасибо:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Вступление
Всем привет! Давно не было никакого материала от меня, хоть обещаний было много… Ну… как вам сказать…
Ладно, сейчас не об этом. Ручная деобфускация строк это довольно нудный и монотонный процесс, и заниматься этим когда ваш пик концентрации достигает 3 минуты невозможно… Может быть, вы также собираете мусор в своей квартире, который копится неделями? Ну и я об этом, поэтому сегодня мы за 15 минут напишем свой скрипт, который решит эту сложную проблему.
Почему в названии 15 секунд, а на деле 15 минут? Потому что конечное решение позволит вам деобфусцировать строки в функции за 15 секунд.
Осматриваем пациентаАвтор показывает лишь базовую концепцию ленивого реверс-инжиниринга. Он не предоставляет универсальных решений для деобфускации всего, думайте своей головой.
В нашем арсенале сегодня будет два бинарных файла:
- Пожалуйста, авторизуйтесь для просмотра ссылки.(снимок памяти был сделан yovimi для его кряка )
- Pandora
Fatality
Пример №1
Рассмотрим первый случай шифрование строк
Код:
mov eax, 0B8CDCAC7h
mov ecx, 0D2FA87CDh
mov [esi+84h], ecx
mov [esi+80h], eax
mov eax, 0BAC7E9ABh
mov ecx, 4D5D5218h
mov edx, 0D2FAEBA2h
mov edi, 0CCBEA3B7h
mov [esi+54h], edx
mov [esi+50h], edi
mov [esi+8Ch], eax
mov [esi+88h], ecx
mov [esi+5Ch], eax
mov [esi+58h], ecx
xor eax, eax
lea ecx, [esi+50h] ; Moving key to ecx
movaps xmm0, xmmword ptr [esi+80h] ; Mov the crypted string to xmm0
xorps xmm0, xmmword ptr [ecx] ; Dexor our string to xmm0
movaps xmmword ptr [esi+80h], xmm0 ; Write our part of the string to the buffer
lea ebx, [esi+80h]
push ebx ; Push string buffer
call some_func
Код:
1: movaps xmm0, xmmword ptr [esi+80h]
2: xorps xmm0, xmmword ptr [ecx]
3: movaps xmmword ptr [esi+80h], xmm0
- Кладётся отрывок зашифрованной строки в xmm0
- Выполнятся конечная операция дешифрования, после которой в xmm0 содержатся отрывок строки длиной 128 бит.
- Записывается отрывок чистой строки в тоже место, где лежала зашифрованная
В декомпилированном виде это выглядит так:
Я потерял рассматриваемый нами кейс, поэтому константы будут отличаться, но это не важно!
C:
decrypted_part.m128_u64[0] = 0xDF8AE292606BC656ui64;
decrypted_part.m128_u64[1] = 0xD54021B748729F38ui64;
key.m128_u64[0] = 0xDF8AE292606BB573ui64;
key.m128_u64[1] = 0xD54021B748729F38ui64;
decrypted_part = _mm_xor_ps(decrypted_part, key);
Код:
* Начало пропущено в целях экономии пространства. *
mov [esi+70h], edx
mov [esi+7Ch], ecx
mov [esi+78h], eax
lea ecx, [esi+40h]
xor eax, eax
movaps xmm0, xmmword ptr [esi+0A0h]
xorps xmm0, xmmword ptr [ecx]
movaps xmmword ptr [esi+0A0h], xmm0
movaps xmm0, xmmword ptr [esi+0B0h]
xorps xmm0, xmmword ptr [ecx+10h]
movaps xmmword ptr [esi+0B0h], xmm0
movaps xmm0, xmmword ptr [esi+0C0h]
xorps xmm0, xmmword ptr [ecx+20h]
movaps xmmword ptr [esi+0C0h], xmm0
movaps xmm0, xmmword ptr [esi+0D0h]
xorps xmm0, xmmword ptr [ecx+30h]
movaps xmmword ptr [esi+0D0h], xmm0
Я с вами полностью согласен, но если разбирать небольшими частями, то в результате это всё тот-же
Пожалуйста, авторизуйтесь для просмотра ссылки.
. Но нас интересует вовсе не это! Тут стоит увидеть, что большая строка собирается частями:
Код:
* ... *
1: movaps xmmword ptr [esi+0A0h], xmm0
* ... *
2: movaps xmmword ptr [esi+0B0h], xmm0
* ... *
Рассмотрим детальнее
- Начало нашей строки находится по [esi + 0x0A0] .Туда записываются первые 16 байт
- Теперь записывается в сомнительное место [esi + 0x0B0]. Отнимем это смещение от нашего начального: 0x0B0 - 0x0A0 = 0x10 = 16. Регистр xmm0 также размером 16 байт, получается оно записывает строку частями!
В декомпилированном виде это выглядит так:
C:
string_part_1.m128_u64[0] = 0xBC8F8D82BFD7CBC3ui64;
string_part_1.m128_u64[1] = 0xDBA4C9C52234267Bui64;
string_part_2.m128_u64[0] = 0xDC928B87F2020A7i64;
string_part_2.m128_u64[1] = 0xD09CD5214CD292AFui64;
string_part_3.m128_u64[0] = 0x72A2250BB64109ABi64;
string_part_3.m128_u64[1] = 0x7E5E9E717B0F4253i64;
string_part_4.m128_u64[0] = 0x169A107835C7D17Ci64;
string_part_4.m128_u64[1] = 0x21D2CB6129134B1Ei64;
key_1.m128_u64[0] = 0xD2FAEBA2CCBEA3B7ui64;
key_1.m128_u64[1] = 0xBAC7E9AB4D5D5218ui64;
key_2.m128_u64[0] = 0x6FE951D4114F00C9i64;
key_2.m128_u64[1] = 0xB4F9B94D2DB1B2CAui64;
key_3.m128_u64[0] = 0x1CCD0566D9336F8Bi64;
key_3.m128_u64[1] = 0x5776EA1F126E320Ci64;
key_4.m128_u64[0] = 0x169A107835C7D15Di64;
key_4.m128_u64[1] = 0x21D2CB6129134B1Ei64;
string_part_1 = _mm_xor_ps(string_part_1, key_1);
string_part_2 = _mm_xor_ps(string_part_2, key_2);
string_part_3 = _mm_xor_ps(string_part_3, key_3);
string_part_4 = _mm_xor_ps(string_part_4, key_4);
some_func(&string_part_1);
Написание шаблона
Чтобы найти все экземпляры шифрования строк нужно составить шаблон. Мы будем составлять шаблон, опираясь всего на две инструкции, потому что мы слишком ленивы, чтобы делать его более точным.
1: 0F 28 86 80 00 00 00 | movaps xmm0, xmmword ptr [esi+80h]
2: 0F 57 02 | xorps xmm0, xmmword ptr [edx]
Разбираем эти инструкции по частям:
Данное объяснение не является полностью правильным, оно было специально упрощено автором для более простого восприятия
- 0F 28- это опкод самой инструкции, т.е **movaps xmm0, 86 80 00 00 00** - это константа, откуда будет выполнено перемещение в xmm0
- 0F 57 - это также опкод инструкции, т.е **xorps xmm0 02**- это константа, с кем будет выполнена операция xor
Составлять шаблон будем вида текста в шестнадцатеричном формате ( IDA Style ).
- Нужно отсеять константы, потому что они могут отличаться у разных экземпляров.
- Нужно отметить, это опкоды инструкций, которые остаются постоянными для всех экземпляров. В нашем случае это 0F 28 для инструкции movaps и 0F 57 для инструкции xorps.
Теперь мы можем составить шаблон для поиска всех экземпляров шифрования строк:
0F 28 ? ? ? ? ? 0F 57
Этот шаблон будет искать все вхождения, где первая инструкция - movaps xmm0, а вторая инструкция - xorps xmm0.
Теперь можно использовать этот шаблон для поиска всех мест в коде, где происходит дешифрование строк.
Тестирование шаблона
Поскольку мы слишком ленивы, чтобы написать собственное решение для тестирования шаблона, мы воспользуемся IDA Pro и их поиском по последовательности байтов.
Целых 1274! Но стоит отметить, что наш паттерн не точный, и мы нашли еще часть простой математики никак не связанной с дешифрованием строк.
Начинаем писать полу-эмулятор
Для нашего ленивого деобфускатора мы будем использовать Python, ведь его биндинги имеет абсолютно каждая библиотека.
Обсуждения работы
Первая версия нашего деобфускатора будет работать в пределах одной функции, и полностью игнорировать control flow.
Принцип работы:
Пункт 4а не будет использован в этой статье. Он оставлен читателю как идея для самостоятельной реализации.
- Находим по шаблону наши инструкции
- Считаем длину movps+ **xorps**, чтобы эмулятор всегда понимал до какого адреса ему нужно эмулировать
- Инициализируем эмулятор, устанавливаем хуки, и начинаем эмуляцию
- Если инструкция подразумевает какой-то прыжок, или вызов - пропускаем.
- Если инструкция подразумевает какой-то прыжок, или вызов - создаём дополнительную копию эмулятора, которая прыгает, если адрес прыжка находится в пределах функции. Так мы сможем обходить весь control flow, и проэмулировать её полностью!
- После завершения эмуляции - читаем **xmm0**, и выводим пользователю.
Зарождение надежды
Мы начинаем с чистого листа — пустого файла, ожидающего первых строк логики. Этот чистый холст представляет собой генезис нашего творения, перед которым открывается безграничный потенциал.
Вспомогательные функции
Итак, начнём! Первым делом нам нужна функция которая будет загружать наш бинарный файл
Python:
def open_binary(path):
with open(path, "rb") as f:
return f.read()
Для сканирования бинарного файла на шаблоны сначала нам нужно преобразовать наш текст в шестнадцатеричном формате в массив байтов.
“?” - будем интерпретировать как -1
Python:
def convert_pattern_to_decimal(pattern):
split_pattern = pattern.split()
decimal_values = [-1 if '?' in hex_char else int(hex_char, 16) for hex_char in split_pattern]
return decimal_values
Теперь сама функция сканирования бинарного файла на шаблон:
Python:
def pattern_scan_in_binary(binary, pattern):
pattern_length = len(pattern)
return [
i for i in range(len(binary) - pattern_length + 1)
if all(p == -1 or b == p for b, p in zip(binary[i:i+pattern_length], pattern))
]
Главная возможность этой функции также остается за читателем! Например можно находить начало функции с помощью словаря, и благодаря этому сканировать абсолютно весь бинарный файл.
Python:
def get_instruction_dict(capstone, binary, addr, size):
instruction_dict = {}
for instruction in capstone.disasm(binary[addr:addr+size], addr):
instruction_dict[instruction.address] = instruction
return instruction_dict
Проверим?
Python:
def main():
binary = get_binary(BINARY_PATH)
capstone = Cs(CS_ARCH_X86, CS_MODE_32)
print("[?] Capstone version: {}".format(cs_version()))
print("[+] Binary size: 0x{:X}".format(len(binary)))
bytes_pattern = convert_pattern_to_decimal(PATTERN)
instruction_dict = get_instruction_dict(capstone, binary, 0x0, len(binary))
if len(instruction_dict) == 0:
print("[-] Can't get instruction dict")
return
pattern_occurrences = pattern_scan_in_binary(memory_function, bytes_pattern)
print("[+] Found {} occurrences".format(len(pattern_occurrences)))
Работает! Наш сканер шаблонов не потерял ни одного экземпляра, и сравнился с IDA Pro.
Подготовка к эмуляции
Есть одна проблема. Сканер находит начало инструкции movaps после совпадения с нашим шаблоном. Однако для эмуляции нам необходимо, чтобы адрес был началом инструкции после xorps.
Чтобы найти правильный адрес:
- Начинаем с начала movaps . Это адрес сразу после совпадения шаблонов.
- Дизассемблируем инструкции одну за другой, добавляя их длину.
- Останавливаемся при достижении инструкции xorps .
- Общая длина от movapsдо xorps это наше смещение
- Добавляем это смещение к начальному адресу.
В итоге после этих шагов мы получаем конечный адрес эмуляции.
Python:
# Set the initial offset to the location of the pattern occurrence in the function's memory
offset = occurrence
# Try to disassemble the first instruction at the offset
instruction = next(capstone.disasm(memory_function[offset:], offset), None)
if instruction is None:
print("[-] No instruction found.")
return
# Initialize size with the size of the first instruction
size = instruction.size
while instruction.mnemonic != 'xorps':
# Increment the offset by the size of the previous instruction
offset += size
# Disassemble the instruction at the new offset
instruction = next(capstone.disasm(memory_function[offset:], offset), None)
# Break the loop if no more instructions available
if instruction is None:
break
# Add size of the current instruction to total size
size += instruction.size
Ох, мы совсем забыли инициализировать наш эмулятор, это вы виноваты!
Python:
def setup_main_emulator(binary_data):
""" Initialize main emulator with binary data. """
emulator = Uc(UC_ARCH_X86, UC_MODE_32)
page_size = 0x1000 # Size for memory page.
alignment = (len(binary_data) + page_size - 1) & -page_size # Ensure address is aligned correctly.
print("[+] Preparing to allocate binary for Unicorn Emulator. Size: 0x{:X}".format(alignment))
allocate_addr = BINARY_ALLOCATED_ADDRESS
emulator.mem_map(allocate_addr, alignment) # Map memory in emulator.
# Write the binary to the mapped memory.
emulator.mem_write(allocate_addr, binary_data)
stack_addr = 0x0
stack_size = 0x100000 # 1MB size of stack.
emulator.mem_map(stack_addr, stack_size) # Map memory for stack.
return emulator
Для начала нам нужно написать промежуточный хук, чтобы проверять какую инструкцию эмулятор собирается исполнять. В общем процесс выглядит примерно так:
- Читаем размер инструкции по текущему адресу, после чего дизассемблируем её
- Если инструкция call / jmp/ jne/ je/ jg/ jae/ jbe/jb то перезаписываем EIP на ( current EIP + размер инструкции )
- Если инструкция ret то заканчиваем эмуляцию, потому что дальше будет не валидная эмуляция
Python:
def instruction_hook(emu, address, size, user_data):
if address < EMULATE_FUNC_START or address > EMULATE_FUNC_END:
emu.emu_stop()
insn_mem = emu.mem_read(address, size)
instruction = next(md.disasm(insn_mem, address))
if instruction.mnemonic in ['call', 'jmp', 'jne', 'je', 'jg', 'jae', 'jbe', 'jb']:
emu.reg_write(uc.x86_const.UC_X86_REG_EIP, address + size)
elif instruction.mnemonic == 'ret':
emu.emu_stop()
Python:
def hook_invalid_memory_access(mu, access, address, size, value, user_data):
print(f"[+] Memory read at 0x{address:x} is not mapped, mapping it.")
# Map memory
mu.mem_map(address & ~(0x1000 - 1), 0x1000)
# Return True to indicate we want to continue emulation
return True
Давайте рассмотрим подробнее, что нам нужно сделать в функции эмуляции:Весь скрипт написан так, чтобы читатель имел возможность для его модификации, и улучшения, пользуясь рекомендациями со статьи.
- Очистить стек
- Очистить все регистры
- Установить хуки
- Начать эмуляцию
- После завершения эмуляции прочитать xmm0, еcли это предоставляется возможным
Python:
def emulate_main_routine_with_hooks(emu, start_addr, end_addr):
stack_size = 1024 * 1024 # 1MB
# Clear stack
emu.mem_write(0x0, b"\\x00" * stack_size)
# Initialize stack
emu.reg_write(uc.x86_const.UC_X86_REG_ESP, stack_size - 1)
# Initialize machine registers
for reg in [uc.x86_const.UC_X86_REG_EAX, uc.x86_const.UC_X86_REG_EBX, uc.x86_const.UC_X86_REG_ECX,
uc.x86_const.UC_X86_REG_EDX, uc.x86_const.UC_X86_REG_ESI, uc.x86_const.UC_X86_REG_EDI,
uc.x86_const.UC_X86_REG_EBP, uc.x86_const.UC_X86_REG_EFLAGS, uc.x86_const.UC_X86_REG_XMM0, uc.x86_const.UC_X86_REG_XMM1]:
emu.reg_write(reg, 0x0)
# Add hooks
emu.hook_add(uc.UC_HOOK_CODE, new_hook_code)
emu.hook_add(uc.UC_HOOK_MEM_READ_UNMAPPED | uc.UC_HOOK_MEM_WRITE_UNMAPPED, hook_invalid)
try:
emu.emu_start(start_addr, end_addr)
except Exception as err:
print("[-] Exception: {}".format(err))
return
xmm0 = emu.reg_read(uc.x86_const.UC_X86_REG_XMM0)
if xmm0 == 0:
print("[-] XMM0 is zero. Failed to decrypt string")
return
xmm0_high = p64(xmm0 >> 64)
xmm0_low = p64(xmm0 & ((1 << 64) - 1))
try:
xmm0_high_str = xmm0_high.decode("utf-8")
xmm0_low_str = xmm0_low.decode("utf-8")
print("[+] 0x{:X} | {} ".format(end_addr, xmm0_low_str + xmm0_high_str)
except:
print("[-] 0x{:X} | Failed to decrypt string", end_addr)
return
В конечном итоге наша главная функция будет выглядеть так:
Python:
def main():
binary = get_binary(BINARY_PATH)
capstone = Cs(CS_ARCH_X86, CS_MODE_32)
print("[?] Capstone version: {}".format(cs_version()))
print("[+] Binary size: 0x{:X}".format(len(binary)))
bytes_pattern = convert_pattern_to_decimal(PATTERN)
instruction_dict = get_instruction_dict(capstone, binary, 0x0, len(binary))
if len(instruction_dict) == 0:
print("[-] Can't get instruction dict")
return
memory_function = binary[get_file_offset(EMULATE_FUNC_START): get_file_offset(EMULATE_FUNC_START) + EMULATE_FUNC_SIZE]
pattern_occurrences = pattern_scan_in_binary(memory_function, bytes_pattern)
print("[+] Found {} occurrences".format(len(pattern_occurrences)))
emulator = setup_main_emulator(binary)
for occurrence in pattern_occurrences:
print("[+] Found at 0x{:X}".format(occurrence))
# Set the initial offset to the location of the pattern occurrence in the function's memory
offset = occurrence
# Try to disassemble the first instruction at the offset
instruction = next(capstone.disasm(memory_function[offset:], offset), None)
if instruction is None:
print("[-] No instruction found.")
return
# Initialize size with the size of the first instruction
size = instruction.size
while instruction.mnemonic != 'xorps':
# Increment the offset by the size of the previous instruction
offset += size
# Disassemble the instruction at the new offset
instruction = next(capstone.disasm(memory_function[offset:], offset), None)
# Break the loop if no more instructions available
if instruction is None:
break
# Add size of the current instruction to total size
size += instruction.size
offset += size
emulate_main_routine_with_hooks(emulator, EMULATE_FUNC_START, EMULATE_FUNC_START + offset )
print("[+] Done. Decrypted all strings in function")
Тестируем наш скрипт
Давайте выполним наш скрипт на небольшую функцию, и проверим его работоспособность.
Да! Он действительно работает, хоть и не обрабатывает все случаи. Базовые возможности его улучшения описаны в статье, но это всего лишь базовые! Всё ограничивается вашей фантазией.
Конец?
В этой статье мы рассмотрели метод автоматического поиска и эмуляции дешифрования строк с помощью Python. Несмотря на то, что этот метод является базовым, он демонстрирует, как сочетание статического анализа и динамической эмуляции может позволить дальше отдыхать на диване, вместо того, чтобы тратить десятки минут на ручное дешифрование строк.
До следующей встречи, через 152 дня.
Мой шизоблог:
Пожалуйста, авторизуйтесь для просмотра ссылки.
Спасибо:
- Easton21: За помощь в редактировании статьи
- kaazedev: За критику текста
- Разработчикам Unicorn / Capstone: За возможность настолько абстрактно писать код
Последнее редактирование: