Как подружить DynamoRIO и LibFuzzer

от автора

Введение

Приветствую всех обитателей Хабра и случайных гостей!

Этой статьёй я хотел бы начать цикл заметок, посвящённых моей научной работе в вузе, связанной с фаззинг-тестированием. Всего на данный момент я работаю над темой 2 семестра.

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

Надеюсь, кому-нибудь это да пригодится 😉

Источники

Преимущественно я буду отсылаться к официальной документации DynamoRIO и некоторым статьям (преимущественно англоязычным), подкинутым мне научруком.

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

Мой репозиторий со всеми наработками и тестами вот тут.

Терминология

Для тех, кто далёк от темы или просто пришёл за конкретными идеями по своим делам, стоит разъяснить основные термины, используемые в статье.

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

Фаззер, соответственно, — программа, осуществляющая фаззинг.
Хорошие статьи с Хабра для ознакомления тут и тут.

LibFuzzer — фреймворк фаззинг-тестирования от LLVM для LLVM компилируемых программ.

Ключевой особенностью является написание тестов как обычной программы с последующей компиляцией стандартным Clang (для C/C++). Короче, очень удобно и просто. При этом ещё и быстро за счёт in-process (кажется) техники работы с запусками.
Их репозиторий и официальная страничка.

DynamoRIO (DR) — библиотека динамической бинарной инструментации (ранее независимая, сейчас Google). Позволяет анализировать и изменять код исполняемой программы.

Так же их репозиторий и документация.

Идея

Цель работы — модифицировать фаззинг с LibFuzzer так, чтобы фаззить ассемблерные функции и вставки в криптографическом ПО эффективно.
Если говорить более конкретно, хотим научиться помогать LibFuzzer фаззеру видеть, что происходит в ассемблерных частях программы.

Про фаззинг ассемблерных вставок

Как работает фаззинг

Для начала немного о том, как работает фаззер (вообще эта модель справедлива скорее для AFL, но большинство современных фаззеров включая LibFuzzer работает примерно по тому же принципу).
Всю программу можно поделить на блоки. Наименьшая единица такого деления в фаззинге — базовый блок.

Базовый блок (base block = bb)- это наибольший непрерывный набор ассемблерных инструкций, которые выполняются строго последовательно без переходов.

Если проще, bb это часть кода без инструкций перехода: без if-else, jmp и т.д. При попадании в такой блок будут выполнены все инструкции, оставшиеся в нем до конца (при отсутствии вмешательства извне, разумеется).

Так вот, на сегодняшний день самым сбалансированным и популярным методом является покрытие-ориентированный серый фаззинг (coverage-oriented grey fuzzing).
И это самое покрытие как-раз-таки основывается на том, какие bb и в каком порядке фаззер посетил при очередном запуске. На основании этой траектории прохождения строится своеобразный слепок пути фаззера — трейс (trace).

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

current_location = <BASE_BLOCK_ID>; trace[current_location ^ (previous_location >> 1)]++; previous_location = current_location;

где BASE_BLOCK_ID — некоторая уникальная константа, присвоенная данному базовому блоку.

Таким образом, после исполнения программы мы получаем участок памяти trace, хранящий своего рода хэш исполнения программы на некоторых входных данных.

Так чего же не хватает?

Тут есть несколько подводных камней при работе с asm-вставками.

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

Во-вторых, что касается уже непосредственно криптографичекого ПО, в asm-коде бывают особенности, связанные, например, с constant-time-execution programming и without-branching programming, предотвращающие появление временных каналов утечки информации, но которые в то же время делают использование bb-based систему составления попросту неэффективной: блок как бы один, но он длинный, и условия в нём всё равно есть. Поэтому хотелось бы вытягивать из этих asm-вставок, где сосредоточены такие важные блоки, максимум.

Решение проблемы

Предлагается следующее решение.
Пусть используется классический фаззинг с LibFuzzer. Тогда поверх него мы запустим клиент DynamoRIO, который будет подсказывать LibFuzzer, что происходит в его слепой зоне (то бишь asm-вставках).

К нашему безразмерному счастью, у LibFuzzer есть специально предусмотренная секция памяти extra_counters, в которую может быть записано дополнительное покрытие. Туда мы и будет трейсить информацию, добытую через клиент DynamoRIO.

Реализация

Ключевыми пунктами тут является следующее:

  1. Как найти из DR-клиента адрес extra_counters (области памяти для доп трейса)

  2. Как найти адреса ассемблерных вставок / функций

  3. Как и что записать в extra_counters

Схема работы получившегося фаззинга под клиентом приведена на схемке. Если непонятно, можете продолжить читать и потом вернуться сюда — я поясняю за работу DynamoRIO клиента далее.

scheme

Схема работы DynamoRIO клиента и LibFuzzer фаззера

Адреса

Начнём с адресов. Это наиболее обособленная часть.

Работать, не зная адресов кусков asm-кода, которые мы дополнительно анализируем, и не зная, куда записывать дополнительное покрытие, мы не в состоянии.

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

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

Главное, понять, как хранятся эти адреса в памяти. В понимании этого очень здорово мне помогла вот эта статья (она на английском).

По сути все именованные сущности — символы (symbols) — в Linux (я работал именно на этой платформе) записаны в табличках .symtab или .dynsym. Если я правильно понял, вторая от первой отличается только тем, что хранит исключительно GLOBAL символы.

Таким образом, мы можем узнать адрес (он же отступ) почти любой именованной сущности, просто достав его из таблички .symtab. Доставать можно либо вручную, например, с помощью readelf или objdump, либо можно воспользоваться DynamoRIO (в который, к слову, фактически встроена адаптированная реализация библиотеки libelf).

Через терминал:

readelf -aW <elf_file_name> | grep <func_name>

В DR для таких целей есть функции вроде drsym_enumerate_symbols или drsym_lookup_symbol.
Они тоже проходятся по .symtab, и можно выбирать необходимые символы.
Если же требуется найти адрес начала и конца — просто найдём символ с адресом, следующим сразу за известным адресом начала в отсортированном массиве.
Главное обращать внимание на тип символа и не забывать о платформе исполнения. ((А то будете как я пол семестра не понимать, почему же функция не видит LOCAL символ, а оказывается, что функция, которую ты использовал смотрит только .dynsym, в которой только GLOBAL символы.))

Например, вот моя функция для нахождения адресов начала и конца функций по их именам (там есть ещё кое-какие навороты, но их можете опустить).

std::map<std::string, std::pair<generic_func_t, generic_func_t>>  get_func_bounds_optimized(std::map<std::string, FuncConfig> inspect_funcs, bool use_pattern, bool use_default_bounds)  {     if (inspect_funcs.empty()) {         dr_printf("[ERROR] : empty instr function map!");         throw std::invalid_argument("[ERROR] : empty instr function map!");     }      // собираем все пары модуль-путь     std::set<std::pair<std::string, std::string>> module_path;     for (auto & func : inspect_funcs) {         module_path.insert(std::make_pair(func.second.module_name, func.second.module_path));     }          // собираем все символы отовсюду     // в результате они уже будут указаны с учётом отступа модуля     std::map<std::string, generic_func_t> symbols;     for (auto & m_p : module_path) {         auto symbols_offests = get_all_symbols_with_offsets(m_p.first, m_p.second, use_pattern);         symbols.merge(symbols_offests);     }      // переводим в вектор, чтобы сортировать было удобнее     std::vector <std::pair<size_t, std::string>> symbols_vector;     for (auto & symbol : symbols) {         symbols_vector.push_back({(size_t) symbol.second, symbol.first});     }      // сортируем символы     std::sort(symbols_vector.begin(), symbols_vector.end());      // для каждой из искомых функций находим границы     std::map<std::string, std::pair<generic_func_t, generic_func_t>> res;     for (auto & func : inspect_funcs) {         std::string func_name = func.first;          // ищем точное совпадение         auto iter = std::find_if(symbols_vector.begin(), symbols_vector.end(), [&func_name](const auto x){             return func_name == std::string(x.second);         });         if ((iter == symbols_vector.end()) && use_pattern) {             dr_printf("second try...\n");             // ищем неточное совпадение             iter = std::find_if(                 symbols_vector.begin(), symbols_vector.end(),                  [&func_name](const auto x){                     return std::string(x.second).find(func_name) != std::string::npos;                 });         }         dr_printf("searching complete!\n");          if (iter == symbols_vector.end()) {             dr_printf("cannot find such func_name =(\n");             // some code         }          if (iter + 1 != symbols_vector.end()) {             res[func_name] = std::make_pair((generic_func_t)iter->first, (generic_func_t)(iter+1)->first);         }     }          return res; }

По сути тут происходит следующие:

  1. сбор символов из всех доступных модулей

  2. строим вектор из пар (address, symbol)

  3. сортируем массив

  4. вытаскиваем пары соседних адресов для каждого искомого символа (имени функции)

Дуболомно, но эффективно.

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

size_t get_symbol_offset(std::string module_name, std::string module_path, std::string symbol_name) {     drsym_init(NULL);     drsym_error_t error;     drsym_debug_kind_t kind;          error = drsym_get_module_debug_kind(module_path.c_str(), &kind);     if (error != DRSYM_SUCCESS) {         perror("error in drsym_get_module_debug_kind() : get_symbol_offset\n");         fprintf(stderr, "ERROR: %d\n", error);         return 0;     } else {         // printf("kind: %d\n", kind);     }      size_t offset = 0;     error = drsym_lookup_symbol(module_path.c_str(),                                  symbol_name.c_str(),                                 &offset,                                 DRSYM_DEMANGLE_FULL);     if (error != DRSYM_SUCCESS) {         perror("error in drsym_lookup_symbol() : get_symbol_offset\n");         fprintf(stderr, "ERROR: %d\n", error);         return 0;     } else {         // printf("offset: %d\n", offset);     }          drsym_exit();      return offset; }

С помощью этой обёртки искались конец и начало секции extra_counters, хранимые в символах __start___libfuzzer_extra_counters и __stop___libfuzzer_extra_counters .

Итого, найти адреса начала и конца можно и не сложно, если знать, как и где искать (пусть и на осознание этого как и где у меня ушёл семестр).

Клиент DynamoRIO

Общие правила написания клиента можно найти в официальной доке и гайдах DR (вообще у DR довольно хорошая в этом плане документация):

Но лично мне в осознании происходящего сильно помогли примеры и копание в исходниках на GitHub:

По сути, клиент должен иметь такой вид:

void handler_1(...) { // some instrumentation } void handler_2(...) { // some instrumentation }  void dr_client_main(client_id_t id, int argc, const char *argv[]) { set_event_1_handler(handler_1); set_event_2_handler(handler_2); }

В main мы развешиваем обработчики на события, происходящие в программе, а уже в обработчиках по ходу исполнения что-то анализируем и инструментируем.

Запуск производится посредством команды вида:

drrun -c <client> <args> -- <app> <args>

Например моя команда запуска выглядит так:

./DynamoRIO-Linux-10.0.19672/bin64/drrun -c ./bin/libclient.so -- ./bin/fuzz_app -max_len=64 -len_control=1 out/corpus

В своём клиенте я использовал следующие обработчики событий:

  • dr_register_exit_event — событие выхода из программы

  • drmgr_register_thread_init_event — создание нового потока

  • drmgr_register_thread_exit_event — закрытие потока

  • drmgr_register_bb_instrumentation_event — обработка нового базового блока

И если с первыми 3 всё более менее понятно, то 4 требует объяснений.

Работа DynamoRIO

Поговорим немного про то, как работает DynamoRIO.

В блоке про фаззинг уже рассказывалось про концепцию базовых блоков (base blocks). Так вот DynamoRIO тоже работает с ними! Только в отличии от фаззеров он работает сразу с байт кодом, и потому его базовый блок — это действительно минимальная неделимая единица.

Так вот, при исполнении программы происходит следующее. Перед исполнением очередного bb, загруженных в RAM инструкций, DynamoRIO даёт возможность их проанализировать (вроде, 4 уровня декодирования) и даже изменить. bb поступают в обработчики соответствующих событий, такие, как, например, наш drmgr_register_bb_instrumentation_event — буквально переводится как «регистрирование инструментации базового блока» — т.е. позволяет повесить обработчик, занимающийся инструментацией базового блока.

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

Такой подход называется Libverify. Подробнее про устройство DynamoRIO рекомендую почитать в официальных презентациях в документации (на английском). Лично мне больше понравилась вот эта презентация, подкинутая научруком, — настоятельно рекомендую к прочтению.

Ещё одним важным моментом, о котором не стоит забывать, является то, что по сути DynamoRIO — это набор утилит, склеенных воедино. Выделяется общее ядро, предоставляющее базовый функционал и, если Вам нужно что-то большее, расширения / модули (ext) . Они, как правило, требуют отдельной инициализации и обычно более платформозависимы.

P.S.: последняя ссылка ведёт на один из таких модулей, просто на папку с ними ссылки нет (

Как же инструментировать?

Ну вот мы и добрались до непосредственно инструментации. Вообще расписывать, что и какая функция делает, смысла я не вижу. Поэтому лишь укажу на ключевые моменты в их использовании и приведу парочку примеров. Детали смотрите в документации, а ещё лучше сразу в коде репозитория DynamoRIO.

Анализ

Ну тут все просто. Основные инструменты для анализа и декодирования инструкций содержатся в ядре DynamoRIO. Ну или можно поискать по дополнительным модулям.
Например я использовал следующие функции для работы:

  • dr_fragment_app_pc — по уникальной метке bb, передаваемой в drmgr_register_bb_instrumentation_event получить отступ / адрес в памяти.

  • dr_app_pc_for_decoding — корректировка адреса (оставил по принципу работает — не трожь)

  • instr_get_opcode — достать опкод ассемблерной инструкции

  • instr_disassemble_to_buffer — дизассемблирует инструкцию в строку. Удобно для дебага, чтобы выводить инструкции ассемблера, которые мы смотрели.

  • instr_get_app_pc — получить адрес инструкции

  • instr_get_dst — получить код регистра назначения

Тут отмечу, что app_pc — это что-то вроде отступа в памяти, по своей сути является переопределённым size_t. Поэтому я везде, где с ним работал, кастовал его в size_t.
В документации про это ничего не нашёл ((- в своё время пришлось чуть ли не на кофейной гуще гадать, что это такое и что с ним можно делать)).

Вот так выглядит мой обработчик для drmgr_register_bb_instrumentation_event.

static dr_emit_flags_t bb_instrumentation_event_handler(                                     void *drcontext,                                      void *tag,                                      instrlist_t *bb,                                      instr_t *instr,                                      bool for_trace,                                      bool translating,                                      void *user_data) {     app_pc bb_addr_1 = dr_fragment_app_pc(tag);     app_pc bb_addr_2 = dr_app_pc_for_decoding(bb_addr_1);      if (address_in_code_segment(tag, code_segment_describers))     {         int op = instr_get_opcode(instr);         if (opcodes.find(op) != opcodes.end()) {             tracer.traceOverflow(drcontext, tag, bb, instr);         }          // логируем         char buff[1024];         instr_disassemble_to_buffer(drcontext, instr, buff, 1024);         main_logger.log("ADDR", int_to_hex((size_t) instr_get_app_pc(instr)));         main_logger.log("INSTR", std::string(buff));     }      return DR_EMIT_DEFAULT; }

Как видите, здесь просто идёт проверка на то, в какой области памяти находится проверяемая инструкция и нужно ли инструментировать инструкцию с таким опкодом.

Вся логика изменения / инструментации кода убрана в метод объекта tracer.

Инструментация

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

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

  1. инструкции приложения (application instructions)

  2. добавленные инструкции (additions = meta instructions)

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

Вставку нового кода можно производить 2 основными путями:

  1. составить последовательность asm-инструкций вручную и вставить их напрямую через функцию instrlist_meta_preinsert

  2. воспользоваться clean_call, которая вставляет функцию на C++.

В первом случае мы на 100% сами ответственны за всё: что вставляем, за регистры, которые используем и за то, какова будет работа программы после исполнения нашего кода. Тут придётся знатно повозиться с макросами опкодов при создании инструкций.

Во втором же случае обо всём позаботится сам DynamoRIO — вставит код, сохраняющий и восстанавливающий регистры, соберёт информацию о контексте исполнения в mcontext и передаст необходимые аргументы в нашу функцию.
Работу clean_call можно разбить на следующие составляющие для более гибкого контроля:

Например, тот самый метод trace.traceOverflow(...) выглядит так:

void  traceOverflow( void *drcontext,  void *tag,  instrlist_t *bb,  instr_t *instr) { opnd_t dst = instr_get_dst(instr, 0); if (!opnd_is_reg(dst)) { return; } reg_id_t dst_reg = opnd_get_reg(dst); int reg_ind = this->get_reg_id(dst_reg);  app_pc instr_pc = instr_get_app_pc(instr); // если эту инструкцию ещё не встречали - выдаём ей номер if (this->pc_ind_map.find(instr_pc) == this->pc_ind_map.end()) { this->pc_ind_map[instr_pc] = this->pc_ind_map.size(); } size_t ind = this->pc_ind_map[instr_pc];  auto * module = dr_get_main_module(); auto pc = module->start; dr_free_module_data(module); size_t start_size_t = (size_t) this->trace_area.start + (size_t) pc;  instr_t *nxt = instr_get_next(instr); dr_insert_clean_call_ex(drcontext,  bb, nxt,  (void *) trace_overflow,  (dr_cleancall_save_t) (DR_CLEANCALL_READS_APP_CONTEXT | DR_CLEANCALL_MULTIPATH), 4,  OPND_CREATE_INT32(start_size_t), OPND_CREATE_INT32(this->trace_area.size), OPND_CREATE_INT32(ind), OPND_CREATE_INT32(dst_reg)); }

Собственно здесь используется dr_insert_clean_call_ex для вставки высокоуровневой функции.

Приводить пример работы с вставкой ассемблерных инструкций мне здесь не хочется, поэтому всех заинтересованных повторно направляю в свой репозиторий — там во всё том же классе Trace содержится прототип альтернативы на asm.
Ну или загляните в статью с Хабра, которую я приводил в начале.

Собираем!

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

int trace_overflow(uint32_t offset, uint32_t size, uint32_t ind, uint32_t reg_id) {     // offset - адрес памяти, куда писать     // size - размер памяти     // ind - индекс add     // reg_id - индекс регистра в DynamoRIO     reg_id_t dst_reg = (reg_id_t) reg_id;     if (size < 65*(ind+1)) {         printf("memory is not enough for tracing\n");     }      // восстанавливаем контекст     dr_mcontext_t mc = { sizeof(mc), DR_MC_ALL};     dr_get_mcontext(dr_get_current_drcontext(), &mc);      // регистр флагов     reg_t xflags = mc.xflags;     // регистр назначения     reg_t reg = reg_get_value(dst_reg, &mc);      // находим индекс старшего бита     int msb_ind_reg = get_msb_ind((uint) reg);      // трейсим     if (msb_ind_reg >= 0) {         ((char *)offset)[(ind*65+msb_ind_reg) % size] += 1;     }     ((char *)offset)[(ind*65+64) % size] += xflags & EFLAGS_CF;          return 0; }

При этом думаю, что проговорить основные шаги работы клиента всё же имеет смысл для улучшения переваривания:

  1. Достаём адрес extra_counters через drsym_lookup_symbol

  2. Достаём адреса начала и конца функций для доп трейса через drsym_enumerate_symbols

  3. Устанавливаем обработчик в dr_client_main на событие drmgr_register_bb_instrumentation_event

  4. В обработчике инструментируем базовые блоки, входящие в пул интересующих нас адресов, вставляя в них код трейсинга с помощью dr_insert_clean_call

  5. Код исполняется, дополнительно заходя в функцию трейсинга и записывая доп покрытие в extra_counters

Результаты

Ну и наконец, не могу не поделиться результатами своих трудов. Получившийся фаззер-франкенштейн показал неплохие результаты при тестировании на криптографическом модуле bn256 библиотеки go-ethereum. (( Мне пришлось знатно попотеть, чтобы скомпилировать Golang модуль в C++ библиотеку с заголовочным файлом. ))

Здесь приведу только основной график среднего исполнения от параметра уязвимости, встроенной в asm-код функции умножения элементов поля Галуа.

График зависимости среднего времени фаззинга от параметра уязвимости

График зависимости среднего времени фаззинга от параметра уязвимости

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

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

В репозитории я разместил свой отчёт о НИР. В нём есть достаточно подробное объяснение того, почему это так работает, на основе теорвера и цепей Маркова.

Совет по дебагу

Отлавливать, почему программа под клиентом падает, вручную довольно сложно, тем более когда речь идёт об отладке ams-кода, вставляемого в код программы, написанного на макросах.
В таком нелёгком деле меня сильно выручил режим логирования DynamoRIO. Почитать в документации можно здесь.
Достаточно просто при запуске установить пару флагов и потом Ctrl^F по логам.

Например я запускал фаззинг под клиентом вот так:

./DynamoRIO-Linux-10.0.19672/bin64/drrun -debug -loglevel 2 -logdir out/dynamorio/ -c bin/libclient.so -- bin/fuzz_app -max_len=64 -len_control=1

По умолчанию логи складируются в /tmp/... папку и удаляются после выключения компьютера.

Главное не ставить слишком высокий уровень логирования, а то там чуть ли не гигабайты логов набегает, и прочесть их — ещё та задачка.

Вместо заключения

На этом мой поток сознания, пожалуй, заканчивается. Мне ещё много чего есть рассказать: о том, как я мучился с компиляцией Golang модуля под C++, как я добавлял уязвимость на Golang-ASM в код этого модуля и о том, как писал кучу скриптов автоматизации. Но это всё уже вторично. Возможно через годик выпущу продолжение данной статьи.

Это моя первая статья на Хабре, поэтому буду читать все комменты 🙂

Спасибо, что дошли до конца. Искренне надеюсь, что Вам было интересно и полезно.


ссылка на оригинал статьи https://habr.com/ru/articles/826932/


Комментарии

Добавить комментарий

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