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

Этот пост является отрывком из моей книги Черная шляпа ржавчины
Получите скидку 42% до Четверг, 11 ноября с купоном 1311B892

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

Сканирование портов — тема целых книг. В зависимости от того, что вы хотите: быть более незаметным, быть быстрее, иметь более надежные результаты и так далее.

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

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

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

ch_02/tricoder/src/ports.rs

use crate::{
    common_ports::MOST_COMMON_PORTS_100,
    model::{Port, Subdomain},
};
use std::net::{SocketAddr, ToSocketAddrs};
use std::{net::TcpStream, time::Duration};
use rayon::prelude::*;


pub fn scan_ports(mut subdomain: Subdomain) -> Subdomain {
    let socket_addresses: Vec<SocketAddr> = format!("{}:1024", subdomain.domain)
        .to_socket_addrs()
        .expect("port scanner: Creating socket address")
        .collect();

    if socket_addresses.len() == 0 {
        return subdomain;
    }

    subdomain.open_ports = MOST_COMMON_PORTS_100
        .into_iter()
        .map(|port| scan_port(socket_addresses[0], *port))
        .filter(|port| port.is_open) // filter closed ports
        .collect();
    subdomain
}


fn scan_port(mut socket_address: SocketAddr, port: u16) -> Port {
    let timeout = Duration::from_secs(3);
    socket_address.set_port(port);

    let is_open = TcpStream::connect_timeout(&socket_address, timeout).is_ok();

    Port {
        port: port,
        is_open,
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Но у нас есть проблема. Выполнение всех наших запросов последовательно очень медленно: если все порты закрыты, мы будем ждать Number_of_scanned_ports * timeout секунды.


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

К счастью для нас, существует API для ускорения программ: потоки.

Потоки — это примитивы, предоставляемые операционной системой (ОС), которые позволяют программистам использовать аппаратные ядра и потоки ЦП. В Rust поток можно запустить с помощью std::thread::spawn функция.

Однопоточный против многопоточного

Каждый поток ЦП можно рассматривать как независимый рабочий процесс: рабочую нагрузку можно разделить между рабочими потоками.

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

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

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

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

Если у нас есть 10 потоков с тайм-аутом в 3 секунды, это может занять до 30 секунд (10 * 3) для сканирования всех портов на наличие одного хоста. Если мы увеличим это число до 100 потоков, то сможем просканировать 100 портов всего за 3 секунды.

Этот пост является отрывком из моей книги Черная шляпа ржавчины
Получите скидку 42% до Четверг, 11 ноября с купоном 1311B892


Бесстрашный параллелизм в Rust

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

TL;DR:


Три причины гонок данных

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


Три правила владения

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


Два правила ссылок

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

Эти правила очень сильно важны и являются основой безопасности памяти и параллелизма в Rust.

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


Добавление многопоточности в наш сканер

Теперь мы увидели, что такое многопоточность в теории. Давайте посмотрим, как это сделать в идиоматическом Rust.

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

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

Вместо этого мы используем район, библиотека параллелизма данных для Rust.

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

ch_02/tricoder/src/ports.rs

// ...
use rayon::prelude::*;

fn main() -> Result<()> {
    // ...
    // we use a custom threadpool to improve speed
    let pool = rayon::ThreadPoolBuilder::new()
        .num_threads(256)
        .build()
        .unwrap();

    // pool.install is required to use our custom threadpool, instead of rayon's default one
    pool.install(|| {
        // ...
    });
    // ...
}

pub fn scan_ports(mut subdomain: Subdomain) -> Subdomain {
    let socket_addresses: Vec<SocketAddr> = format!("{}:1024", subdomain.domain)
        .to_socket_addrs()
        .expect("port scanner: Creating socket address")
        .collect();

    if socket_addresses.len() == 0 {
        return subdomain;
    }

    subdomain.open_ports = MOST_COMMON_PORTS_100
        .into_par_iter() // <- HERE IS THE IMPORTANT BIT
        .map(|port| scan_port(socket_addresses[0], *port))
        .filter(|port| port.is_open) // filter closed ports
        .collect();
    subdomain
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Аааа… Вот и все. Действительно. Мы заменили into_iter() по into_par_iter() (что означает «в параллельный итератор». Что такое итератор? Подробнее об этом в главе 3), и теперь наш сканер будет сканировать все различные поддомены в выделенных потоках.


За кулисами

Это двухстрочное изменение скрывает многое. В этом сила системы типов Rust.


Прелюдия

use rayon::prelude::*;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Использование crate::prelude::* это распространенный шаблон в Rust, когда ящики имеют много важных трейтов или структур и хотят упростить их импорт.

В случае rayonначиная с версии 1.5.0, use rayon::prelude::*; является эквивалентом:

use rayon::iter::FromParallelIterator;
use rayon::iter::IndexedParallelIterator;
use rayon::iter::IntoParallelIterator;
use rayon::iter::IntoParallelRefIterator;
use rayon::iter::IntoParallelRefMutIterator;
use rayon::iter::ParallelDrainFull;
use rayon::iter::ParallelDrainRange;
use rayon::iter::ParallelExtend;
use rayon::iter::ParallelIterator;
use rayon::slice::ParallelSlice;
use rayon::slice::ParallelSliceMut;
use rayon::str::ParallelString;
Войти в полноэкранный режим

Выйти из полноэкранного режима


Тредпул

На заднем плане rayon crate запустил пул потоков и отправил наши задачи scan_ports а также scan_port к этому.

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


Альтернативы

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

ch_02/snippets/threadpool/src/main.rs

use std::sync::mpsc::channel;
use threadpool::ThreadPool;

fn main() {
    let n_workers = 4;
    let n_jobs = 8;
    let pool = ThreadPool::new(n_workers);

    let (tx, rx) = channel();
    for _ in 0..n_jobs {
        let tx = tx.clone();
        pool.execute(move || {
            tx.send(1).expect("sending data back from the threadpool");
        });
    }

    println!("result: {}", rx.iter().take(n_jobs).fold(0, |a, b| a + b));
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

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

Это может быть достигнуто с помощью channel как в приведенном выше примере, где мы «делимся памятью, общаясь».

Или с std::sync::Mutex которые позволяют нам «общаться, делясь памятью». Мьютекс в сочетании с std::sync::Arc умный указатель позволяет нам совместно использовать память (переменные) между потоками.


Асинхронное ожидание

Прежде чем покинуть тебя, я хочу открыть тебе секрет.

Я не рассказал вам всю историю: многопоточность — не единственный способ увеличить скорость программы, особенно в нашем случае, когда большая часть времени уходит на операции ввода-вывода (TCP-соединения).

Добро пожаловать async-await.

У потоков есть проблемы: они были разработаны для распараллеливания ресурсоемких задач. Однако наш текущий вариант использования — интенсивный ввод-вывод (ввод/вывод): наш сканер запускает много сетевых запросов и на самом деле мало что вычисляет.

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

  • Они используют много (по сравнению с другими решениями) памяти
  • Запуски и переключение контекста имеют свою цену, которую можно почувствовать, когда выполняется много (десятки тысяч) потоков.

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

Как использовать async-await вместо ниток? Давайте узнаем в главе 3.

Хотите узнать больше о Rust, прикладной криптографии и безопасности? Взгляните на мою книгу Черная шляпа ржавчины.

Получите скидку 42% до Четверг, 11 ноября с купоном 1311B892