pspyを理解する

ToC

pspyとは

Linuxにおけるプロセススパイのツールです。

GitHub - DominicBreuker/pspy: Monitor linux processes without root permissions
Monitor linux processes without root permissions. Contribute to DominicBreuker/pspy development by creating an account on GitHub.
GitHub - DominicBreuker/pspy: Monitor linux processes without root permissions favicon https://github.com/DominicBreuker/pspy
GitHub - DominicBreuker/pspy: Monitor linux processes without root permissions

主な用途は、カレントユーザー以外のユーザー権限で実行されているプロセスのコマンドラインを確認することです。

例えばカレントユーザー以外の権限で設定されたCronジョブが定期的に実行されている場合、/var/spool/cron以下に設定ファイルがあるとroot権限無しではそれぞれの設定を読み取ることができませんが、pspyを対象ホスト上で実行することで定期実行されたプロセスのコマンドラインを確認することができます。

psコマンドなどでもプロセスのコマンドラインは取得できますがpspyを使用すると生存期間がごく短いプロセスも捕捉することができます。

HackTheBoxなどのBoot2RootCTFの簡単な問題では、コマンドライン引数にクレデンシャルを設定しているプロセスがあったり、誰でも読み書きできるシェルスクリプトをroot権限で実行している、など権限昇格の手がかりになることがあります。

pspyはprocfsを監視することでプロセスの情報を取得しています。

procfs

procfs/procにマウントされているプロセス情報を管理しているファイルシステムです。

ファイルシステムと言ってもデータの実態はメモリ上に存在しており、procfsへのアクセスによるディスクアクセスは発生しません。

プロセス管理用のシステムコールを削減するために実装された仕組みのようです。

/proc

新しくプロセスが実行された場合、そのプロセスのPIDでディレクトリが切られるため、このディレクトリを監視することで現在実行されているプロセスのPID一覧が取得できます。

image

/proc/[PID]

このディレクトリには[PID]のプロセスに関する情報が存在します。

image

/proc/[PID]/cmdline

このファイルを読み取ることで[PID]のプロセスが開始された際のコマンドラインが確認できます。

image

/proc/[PID]/status

以下のようにプロセスの様々なステータスが確認できます。

が、pspyが使用するのはUidGidの部分のみだと思います。

Name:   npm run preview
Umask:  0022
State:  S (sleeping)
Tgid:   5042
Ngid:   0
Pid:    5042
PPid:   4995
TracerPid:      0
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000
FDSize: 64
Groups: 4 20 24 25 27 29 30 44 46 100 106 111 117 124 125 993 1000
NStgid: 5042
NSpid:  5042
NSpgid: 5042
NSsid:  4995
Kthread:        0
VmPeak:   795820 kB
VmSize:   731052 kB
VmLck:         0 kB
VmPin:         0 kB
VmHWM:     81520 kB
VmRSS:     73228 kB
RssAnon:           31628 kB
RssFile:           41600 kB
RssShmem:              0 kB
VmData:   102284 kB
VmStk:       132 kB
VmExe:        16 kB
VmLib:     38612 kB
VmPTE:      1504 kB
VmSwap:        0 kB
HugetlbPages:          0 kB
CoreDumping:    0
THP_enabled:    1
untag_mask:     0xffffffffffffffff
Threads:        15
SigQ:   0/30943
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000001001000
SigCgt: 0000000108014602
CapInh: 0000000800000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
NoNewPrivs:     0
Seccomp:        0
Seccomp_filters:        0
Speculation_Store_Bypass:       thread vulnerable
SpeculationIndirectBranch:      conditional enabled
Cpus_allowed:   ff
Cpus_allowed_list:      0-7
Mems_allowed:   00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000001
Mems_allowed_list:      0
voluntary_ctxt_switches:        58
nonvoluntary_ctxt_switches:     26

ここまでの説明で、プロセスの監視をするなら/proc以下を無限ループで/proc/[PID]のディレクトリリストを取得し、それぞれの/proc/[PID]/cmdlineを読み取ればpspyと同じことができるんじゃないかと思われた方もいるかと思います。

実際、そのとおりで下記のような簡単なプログラムでpspyと同様の処理が可能です。

Pythonコード
import os
import re
from concurrent.futures import ThreadPoolExecutor, wait, ALL_COMPLETED

plist = dict()

def get_processinfo(p):
    cmdline = open(f"/proc/{p}/cmdline","r").read()
    plist[p] = cmdline.replace("\x00"," ").strip()

    status = open(f"/proc/{p}/status","r").read()
    m = re.search(r"Uid:\t\d+\t\d+\t(?P<uid>\d+)", status)
    uid = m.group("uid")
    print(f"PID: {p} | UID: {uid} | {plist[p]}")

def main():
    while True:
        pids = [f.name for f in os.scandir("/proc") if f.name.isdigit() and f.name not in plist]
        if len(pids) == 0:
            continue
        with ThreadPoolExecutor(max_workers=len(pids)) as executor:
            tasks = [executor.submit(get_processinfo, p) for p in pids]
            wait(tasks, return_when=ALL_COMPLETED)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        os._exit(0)

image

root権限で実行されたプロセスのコマンドラインも取得できていることがわかります。

image

問題点

ただし、このプログラムには問題があります。

CPU使用率

無限ループで絶え間なく処理を続けているため、上記スクリプト実行中はCPU使用率が99%~100%程度に張り付きます。

image

この状態だとシステム管理者側に怪しい挙動として検知される可能性があります。(HackTheBoxなどのBoot2Rootではこのスクリプトでも十分かと思いますが。)

また、ペネトレーションテストで使用する場合、顧客側の環境に負荷を掛け過ぎて環境を破壊する可能性もあるので安易には使用できません。

改善してみる

CPU使用率を低減するため、上記のスクリプトに少し手を入れてみます。

具体的には、1度のループ処理の後、処理を任意の秒数ブロックさせます。

改善後のコード
import os
import re
+ import time
from concurrent.futures import ThreadPoolExecutor, wait, ALL_COMPLETED

plist = dict()

def get_processinfo(p):
    cmdline = open(f"/proc/{p}/cmdline","r").read()
    plist[p] = cmdline.replace("\x00"," ").strip()

    status = open(f"/proc/{p}/status","r").read()
    m = re.search(r"Uid:\t\d+\t\d+\t(?P<uid>\d+)", status)
    uid = m.group("uid")
    print(f"PID: {p} | UID: {uid} | {plist[p]}")

def main():
    while True:
        pids = [f.name for f in os.scandir("/proc") if f.name.isdigit() and f.name not in plist]
        if len(pids) == 0:
            continue
        with ThreadPoolExecutor(max_workers=len(pids)) as executor:
            tasks = [executor.submit(get_processinfo, p) for p in pids]
            wait(tasks, return_when=ALL_COMPLETED)
+           time.sleep(1)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        os._exit(0)

めちゃくちゃ簡単なテコ入れですが、CPU使用率が高くなる時間を抑えられます。

image

このテコ入れでCPU使用率の問題はクリアできたように見えますが、また別の問題が浮上します。

生存期間の短いプロセスの取りこぼし

テコ入れ前のスクリプトとテコ入れ後のスクリプトを同時に実行してみると、テコ入れ前のスクリプトではキャッチできているプロセス情報がテコ入れ後のスクリプトではキャッチできないことがあります。

テコ入れ前

image

テコ入れ後

image

テコ入れ後のスクリプトではgrepなどのプロセスがキャッチできていません。

ループ後のブロック中に起動され、ブロック中に生存期間が終了するプロセスはこの修正では拾えないことになります。

pspyがこれらの問題点をどのように回避しているか

前の章で挙げた問題点をpspyはInotify APIというものを使用して回避しています。

Inotify APIはLinuxのファイルシステムイベントを監視するためのAPIです。

プロセスが起動する際は何かしらのファイルにアクセスするため、そのアクセスイベントをInotify APIを使用してキャッチし、それをトリガーとしてprocfsを列挙する、というのがpspyの基本的な仕組みです。

Inotify API

上述の通りInotify APIはLinuxのファイルシステムイベントを監視するためのAPIです。

以下はman pageからの引用です。

inotify API はファイルシステムイベントを監視するための機構を提供する。
inotify は個々のファイルやディレクトリを監視するのに使える。
ディレクトリを監視する場合、inotify はディレクトリ自身と ディレクトリ内のファイルのイベントを返す。

Inotify APIを使用するには以下のシステムコールを使用します。

inotify_init1

Inotify APIを使用するにはまず、inotify_init1を使用してInotify用のfdを初期化します。

inotify_init1には引数にIN_CLOEXECIN_NONBLOCKフラグを指定できますが、今回の用途ではIN_CLOEXECを指定します。理由は後述します。

extern "C" {
    pub fn inotify_init1(flags: i32) -> i32;
}

const IN_CLOEXEC: i32 = 524288;
//const IN_NONBLOCK: i32 = 2048;

fn main() {
    let fd = unsafe { inotify_init1(IN_CLOEXEC) };
}

inotify_add_watch

続いてinotify_add_watchを使用して監視対象のファイルシステムを指定します。
第一引数にinotify_init1で取得したfd、第二引数に監視対象のパス、第三引数に監視するイベントマスクを指定します。

use std::ffi::CString;

extern "C" {
    pub fn inotify_init1(flags: i32) -> i32;
    pub fn inotify_add_watch(fd: i32, pathname: *const i8, mask: u32) -> i32;
}

const IN_CLOEXEC: i32 = 524288;
//const IN_NONBLOCK: i32 = 2048;
const IN_ALL_EVENTS: u32 = 4095;

fn main() {
    let fd = unsafe { inotify_init1(IN_NONBLOCK) };
    //let fd = unsafe { inotify_init1(IN_CLOEXEC) };
    let watch_fd = unsafe {
        inotify_add_watch(
            fd,
            CString::new("/opt/test").unwrap().as_ptr(),
            IN_ALL_EVENTS,
        )
    };
}

監視できるイベントは下記のとおりです。IN_ALL_EVENTSは全てのイベントを監視します。

IN_ACCESS = 0x1,
IN_MODIFY = 0x2,
IN_ATTRIB = 0x4,
IN_CLOSE_WRITE = 0x8,
IN_CLOSE_NOWRITE = 0x10,
IN_CLOSE = 0x8 | 0x10,
IN_OPEN = 0x20,
IN_MOVED_FROM = 0x40,
IN_MOVED_TO = 0x80,
IN_MOVE = 0x40 | 0x80,
IN_CREATE = 0x100,
IN_DELETE = 0x200,
IN_DELETE_SELF = 0x400,
IN_MOVE_SELF = 0x800,

read

監視対象を設定できたらinotify_init1で取得したfdに対し、無限ループでreadを呼び続けます。

このとき、inotify_init1IN_CLOEXECを指定したため、監視対象のイベントが発火するまでreadがブロックします。

反対にinotify_init1IN_NONBLOCKを指定した場合、readを呼んだタイミングで監視対象のイベントが発火していなかった場合、ブロックせず即、次の処理に進みます。

CPUの節約という目的の場合、イベント発火までブロックしてほしいのでIN_CLOEXECを指定しています。

最終的にInotify APIを使用するプログラム全体は下記のようになります。

このプログラムを実行し、/opt/testに文字列を書き込んで見るとイベントの発火を検知して文字列が出力されます。

use std::ffi::CString;

extern "C" {
    pub fn inotify_init1(flags: i32) -> i32;
    pub fn inotify_add_watch(fd: i32, pathname: *const i8, mask: u32) -> i32;
    pub fn read(fd: i32, buf: *mut u8, count: usize) -> isize;
}

const IN_CLOEXEC: i32 = 524288;
const IN_NONBLOCK: i32 = 2048;
const IN_ALL_EVENTS: u32 = 4095;

fn main() {
    let fd = unsafe { inotify_init1(IN_NONBLOCK) };
    //let fd = unsafe { inotify_init1(IN_CLOEXEC) };
    let watch_fd = unsafe {
        inotify_add_watch(
            fd,
            CString::new("/opt/test").unwrap().as_ptr(),
            IN_ALL_EVENTS,
        )
    };

    loop {
        let mut buf = [0_u8;1024];
        let _ = unsafe { read(fd, buf.as_mut_ptr() as *mut u8, buf.len()) };
        println!("Event fired!!");
    }
}

このプログラムは一つのディレクトリ(ファイル)を監視していますが、pspyは/usr,/tmp,/etc,/home,/var,/optなどのフォルダを再帰的に監視することでプロセスの起動をキャッチできる確率を高めています。

実装してみたリポジトリの紹介

ここまででpspyの仕組みが理解できているので、自分でpspyの機能を絞ったツールを作成してみました。

GitHub - r1k0t3k1/thin-pspy
Contribute to r1k0t3k1/thin-pspy development by creating an account on GitHub.
GitHub - r1k0t3k1/thin-pspy favicon https://github.com/r1k0t3k1/thin-pspy
GitHub - r1k0t3k1/thin-pspy

このツールを実行後、下記のような生存期間の短いプロセスを起動してみても…

image

しっかりと補足できていることがわかります。

image

また、CPU使用率を確認してみても、最大で10%、平均して6~7%ほどのCPU使用率に抑えられていることが確認できました。

image

まとめ