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

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

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

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

Идея

Возьмем типичное дерево процессов systemd и попробуем найти в нем something. Тогда спросите себя — насколько это сложно?

serge@satyricon:~$ pstree  
systemd─┬─accounts-daemon───2*[{accounts-daemon}]  
        │ .. omitted for brevity  
        └─sh───node─┬─node─┬─bash  
                    │      ├─bash───sudo───bash───something───2*[{something}]  
                    │      └─12*[{node}]  
                    └─node─┬─node───6*[{node}]

А теперь представьте, что Прометей представляет его как /sshd/base/server.sh/sudo/base/something дерево в process размере. Свернута двойная вложенность node процессов. systemd опущено, потому что это мать всех драконов. Этот инструмент также предоставляет process_seconds гистограммы. Разве не легко определить, что запускается на сервере?

serge@satyricon:~$ curl http://localhost:9501/
process{state="RUNNING",tree="/sshd/base/pstree"} 1
process{state="RUNNING",tree="/sshd/base/server.sh/sudo/base/prom-cnproc"} 1

Пинать шины

Я бросил вызов этой идее, написав Prometheus exporter для деревьев процессов, запущенных в Linux. Можно задаться вопросом, какие процессы запускаются на машинах Linux и ожидаются ли они. Как правило, трудно понять, предназначен ли процесс для запуска или нет. Эта утилита предназначена для мониторинга каждого запуска процесса с низкими накладными расходами, чтобы удалить зашумленные части деревьев процессов. События предоставляются через ядро ​​Linux Process Events Connector.

Эта небольшая утилита пытается добыть ценную информацию о деревьях процессов кратким и малозатратным методом, запуская приложение Rust в пользовательском пространстве. Другая альтернатива может быть построена на технологии Extended Berkeley Packet Filter (eBPF), но это тайная магия пространства ядра. Почему нет? Ну, лучше в следующий раз. Программы eBPF должны быть проверены, чтобы не привести к сбою ядра. Пробовать это в Rust, вероятно, лучше с крейтом bcc.

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

/// Compacts the name for presentation in monitoring
fn tree(pids: &HashMap<i32,Process>, pid: i32) -> String {
    // start with a given pid
    let mut curr = pid;
    // initialize current process tree
    let mut tree = vec![];
    // tree entropy is minumum entropy of any paths of binaries executed in this process tree
    let mut tree_entropy = std::f32::MAX;

    // loop until init process
    while curr != 0 {
        trace!("tree curr={} {}", curr, tree.join("<"));
        if let Some(prc) = pids.get(&curr) {
            curr = prc.ppid;
            // possible optimization: cache label and entropy per pid
            let label = prc.label();
            // call heuristics to detect random filenames
            let path_entropy = prc.entropy();
            if path_entropy < tree_entropy {
                tree_entropy = path_entropy;
            }
            // collapse tree label from unwanted names
            if tree.last() == Some(&label) {
                continue;
            }
            // assume that almost every process tree is started via systemd
            if label == "systemd" {
                continue;
            }
            tree.push(label);
        } else {
            curr = 0
        }
    }
    if tree_entropy < 0.022 {
        // random prefix means that folder with binary was in random location
        tree.push("random");
    }
    tree.reverse();
    
    return format!("/{}", tree.join("/"));
}

Основная логика зависит от крейта cnproc Rust. Мы обновляем внутреннее состояние для каждого запуска и завершения процесса и игнорируем остальные события:

pub fn main_loop(&mut self) -> ! {
    loop {
        if let Some(e) = self.monitor.recv() {
            match e {
                PidEvent::Exec(pid) => self.start(pid),
                PidEvent::Exit(pid) => self.stop(pid),
                _ => continue
            }
        }
    }
}

Каждый раз, когда появляется новый процесс, мы обнаруживаем родительские процессы и устанавливаем шкалу process{state="RUNNING"} на единицу, а шкалу process{state="STOPPED"} на 0, чтобы убедиться, что мы правильно инициализируем требуемые состояния для чего-то похожего на перечисления в мире Prometheus. Я выбрал крейт метрики, в котором представлены макросы, похожие на крейт журнал.

fn start(&mut self, pid: i32) {
    let mut curr = pid;
    while curr != 0 {
        trace!("pid {} > curr {}", pid, curr);
        if self.pids.contains_key(&curr) {
            // eagerly break the cycle if parents 
            // were already discovered
            break;
        }
        let prc = match Process::new(curr) {
            Ok(it) => it,
            Err(e) => {
                warn!("pid {} > {}", curr, e);
                break;
            }
        };
        curr = prc.ppid;
        self.pids.insert(prc.pid, prc);
    }
    let tree = tree(&self.pids, pid);
    gauge!("process", 1.0, "tree" => tree.clone(), "state" => "RUNNING");
    gauge!("process", 0., "tree" => tree.clone(), "state" => "STOPPED");
    debug!("started pid={} tree={}", pid, tree)
}

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

let addr = "127.0.0.1:9501";
let addr: SocketAddr = addr.parse().expect("Unable to parse socket address");
let builder = PrometheusBuilder::new().listen_address(addr);
builder.install().expect("failed to install Prometheus recorder.");

Каждый раз, когда процесс останавливается, мы устанавливаем шкалу process{state="RUNNING"} на ноль и увеличиваем шкалу process{state="STOPPED"}. Мы также увеличиваем гистограмму process_seconds, чтобы получить представление о том, как долго обычно работает конкретное дерево процессов.

fn stop(&mut self, pid: i32) {
    if !self.pids.contains_key(&pid) {
        // don't trigger for before unknown processes
        return;
    }
    let tree = tree(&self.pids, pid);
    let prc = self.pids.remove(&pid).unwrap();
    let elapsed = prc.start.elapsed();
    let seconds = elapsed.as_secs_f64();

    gauge!("process", 0., "tree" => tree.clone(), "state" => "RUNNING");
    gauge!("process", 1., "tree" => tree.clone(), "state" => "STOPPED");
    histogram!("process_seconds", seconds, "tree" => tree.clone());
    debug!("stopped pid={} tree={} duration={:?}", pid, tree, elapsed);
}

Мы используем файловую систему /proc для получения информации, такой как аргументы командной строки, идентификатор родительского процесса или исполняемый файл. Другие утилиты Linux, такие как top или ps, полагаются на эту файловую систему для получения метаданных процесса на самом низком уровне. Я решил не выбирать крейт procfs, чтобы уменьшить количество прямых зависимостей.

impl Process {
    pub fn new(pid: i32) -> Result<Self> {
        let start = Instant::now();
        let argv = cmdline(pid)?;
        let ppid = ppid(pid)?;
        let exe = Path::new(&format!("/proc/{}/exe", pid)).read_link()?;
        let exe = exe.canonicalize()?;
        trace!("{} pid={} ppid={} took={:.2?}", 
            exe.to_str().unwrap_or("..."), pid, ppid, start.elapsed());
        Ok(Process{pid, ppid, argv, exe, start}) 
    }
    //..
}

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

/// Returns minimum metric entropy of any path element
pub fn entropy(&self) -> f32 {
    let mut path_entropy = std::f32::MAX;
    let actual = self.actual_runnable();
    let mut elems = actual.split("/");
    for chunk in &mut elems {
        let entropy = metric_entropy(chunk.as_bytes());
        trace!("entropy {}={}", chunk, entropy);
        if entropy < path_entropy {
            path_entropy = entropy;
        }
    }
    path_entropy
}

Всякий раз, когда мы запускаем скрипт Python или Bash, нас интересует имя скрипта, а не тот факт, что он вызывается /bin/sh. Это означает, что задание cron python /tmp/ZW50cm9weQo/top.py должно отображаться как /random/crond/top.py, где /random будет означать имя папки с высокой энтропией, в которой находится скрипт. Это Linux, и многие исполняемые файлы являются не двоичными файлами, а сценариями Shell или Python.

Для них нас интересует не то, какие конкретные процессы Python выполнялись, а то, какие конкретные сценарии Python выполнялись. Таким образом, мы можем получить некоторые полезные отпечатки дерева процессов, такие как имена shell.py или listener.py, которые могут указывать на намерение исполняемого файла.

/// Determines actual runnable file - binary or script
fn actual_runnable(&self) -> &str {
    let sh  = self.is_shell();
    let py  = self.is_python();
    let has_args = self.argv.len() > 1;
    if (sh || py) && has_args {
        let maybe_script = self.argv[1].as_str();
        // or should it be just regex?..
        let path = Path::new(maybe_script);
        if path.is_file() {
            return maybe_script;
        }
    }
    self.exe.to_str().unwrap_or("/")
}

Последняя важная эвристика уменьшения размерности основана на ручном создании списка двоичных файлов в образе базового дистрибутива с помощью таких команд, как find /usr/sbin -type f | xargs realpath. Всякий раз, когда вызывается базовый бинарный файл Linux, он будет иметь псевдоним base в имени дерева. Это просто означает, что вместо apt-get, bash или ls мы будем видеть метки base, что сделает отпечатки дерева процессов менее шумными.

/// Determines short label to include in process tree
pub fn label(&self) -> &str {
    let path = self.actual_runnable();
    if is_base(path) {
        // base system may have plenty of scripts
        return "base";
    }
    // maybe this will be improved
    let filename = path.split("/").last().unwrap_or("/");
    filename
}

Как вы можете это использовать

Пожалуйста, пометьте и разветвите репозиторий nfx/prom-cnproc (лицензия MIT). Вся работа — это только первоначальный прототип; вы должны использовать его на свой страх и риск. Я использую это в своих песочницах для исследований в области безопасности уже более года. Полученный двоичный файл размером 500 КБ не имеет зависимостей и работает почти без накладных расходов. Этот процесс должен запускаться от имени пользователя root, поскольку другого способа прослушивания соответствующего сокета NetLink не существует. Я был бы рад получить запрос на вытягивание, если есть способ его улучшить.

Загрузите релизный пакет для вашей архитектуры и установите его как dpkg -i prom-cnproc_0.1.0_amd64.deb. Если вам интересно увидеть некоторую отладочную информацию из двоичного файла, RUST_LOG=trace предоставит вам большую часть информации. В настоящее время HTTP-сервер будет прослушивать localhost:9501 , и указать его в качестве конфигурации невозможно. Как только этот процесс экспорта будет запущен, укажите на него свой Prometheus.

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

  • apt-get install libc6-dev-i386
  • cargo install cargo-deb
  • cargo deb --target=aarch64-unknown-linux-gnu
  • cargo deb --target=x86_64-unknown-linux-gnu

Желаю вам счастливого пинания!

Смотрите также

Первоначально опубликовано на https://ssmertin.com 16 апреля 2023 г.