「夏休みの自由研究」ということで「ステルス・スキャン」を Rust で実装しましたのでメモを残します。
ステルス・スキャンとは
「ステルス・スキャン」とは、TCP や UDP の各ポートのサービス状態を総当たりで調査する「ポート・スキャン」において、ターゲットとなるコンピュータにログを残さないように行うスキャン手法です。
例えば、TCP の 3 way ハンドシェイクでは次の 3 ステップでクライアントとサーバの間の TCP コネクションが開通します。
- クライアントからサーバへ SYN パケットを送信
- サーバからクライアントへ SYN-ACK パケットを返信
- クライアントからサーバへ ACK パケットを返信
上の場合はポートが開いているケースですが、ポートが閉じている場合は 2 でサーバからクライアントへ RST-ACK が返ります。
このようにして、クライアントは SYN パケットを送信することで、サーバから返信されるパケットの内容によりそのポートが開いているのか閉じているのかを判別することができます。
またポートが開いている場合に 3 の ACK パケットをサーバに返信せず RST パケットを返信すればTCPコネクションの開通はキャンセルされます。
TCPコネクションが開通しないのであればサーバ側ではサービスアプリケーションは起動しません。このためサービスアプリケーションなどが出力するログが記録されない、というわけです。
まぁ、そうは言ってもサーバ側に痕跡が残らないだけであって、ネットワークを流れるパケットのやりとりをつぶさに監視していれば技術的にはステルス・スキャンも検出は可能です。
想定するコマンドの実行例
ここでは実装するコマンド名は "stealth" と呼称することにします。
また、ステルス・スキャンのように RAW ソケットを使うには root 権限が必要になります。以下の例では sudo を使っております。
インターフェース enp0s3 を使ってターゲット 192.168.56.1 の 8081 番ポートの開通状況を調べるには次のコマンドとなります。
% sudo stealth enp0s3 192.168.56.1:8081
以下にケースごとの実行例を示します。
- ポートがオープンな場合:
% sudo stealth enp0s3 192.168.56.102:8081
Interface: enp0s3
[Sent]
Local: 10.0.2.15:38791
Remote: 192.168.56.102:8081
Flags: SYN
Seq No: 622262747
Ack No: 0
Window: 1000
[Received]
Local: 10.0.2.15:38791
Remote: 192.168.56.102:8081
Flags: SYN,ACK
Seq No: 626816001
Ack No: 622262748
Window: 65535
[Result]
192.168.56.102:8081: open
受信パケットのフラグが SYN,ACK になっていることに注意。
- ポートが閉じている場合:
% sudo target/debug/stealth enp0s3 192.168.56.102:8080
Interface: enp0s3
[Sent]
Local: 10.0.2.15:27240
Remote: 192.168.56.102:8080
Flags: SYN
Seq No: 4275718116
Ack No: 0
Window: 1000
[Received]
Local: 10.0.2.15:27240
Remote: 192.168.56.102:8080
Flags: ACK,RST
Seq No: 0
Ack No: 4275718117
Window: 0
[Result]
192.168.56.102:8080: closed
受信パケットのフラグが ACK,RST になっていることに注意。
- ターゲットに接続できない、あるいは、ポートがフィルタされている場合
% sudo stealth enp0s3 192.168.56.103:8081
Interface: enp0s3
[Sent]
Local: 10.0.2.15:46114
Remote: 192.168.56.103:8081
Flags: SYN
Seq No: 3529367841
Ack No: 0
Window: 1000
[Result]
192.168.56.103:8081: timeout
このケースでは応答パケットが返ってきません。受信しようとしたけどタイムアウトしてしまった、というわけです。
従って、そもそもターゲットに接続できないか、ポートがフィルタされているのだろうと推測するしかありません。
実装
以下のコードは Linux でのみ動作を確認しております。
main 関数では run 関数を呼び出し、エラー時にはエラーメッセージを表示するのみです。
// main.rs
const TIME_OUT_MS: u64 = 5000;
const MSGS: [&str; 4] = ["open", "closed", "timeout", "unknown"];
use pnet::packet as pp;
use pp::ethernet as ppe;
use pp::ipv4::Ipv4Packet;
use pp::tcp as ppt;
use ppe::{EtherTypes, EthernetPacket};
use pd::Channel::Ethernet;
use pd::NetworkInterface;
use pd::{channel, interfaces};
use pnet::datalink as pd;
use pnet::ipnetwork::IpNetwork;
use pnet::transport as pt;
use pp::ip::IpNextHeaderProtocols::Tcp;
use pp::Packet;
use ppt::ipv4_checksum;
use ppt::MutableTcpPacket;
use ppt::TcpFlags::{ACK, CWR, ECE, FIN, PSH, RST, SYN, URG};
use ppt::TcpOption;
use ppt::TcpPacket;
use pt::transport_channel;
use pt::TransportChannelType::Layer4;
use pt::TransportProtocol::Ipv4;
use rand::Rng;
use std::net::{IpAddr, Ipv4Addr};
use std::str::FromStr;
use std::sync::mpsc;
use std::thread::{sleep, spawn};
use std::time::Duration;
const SYN_ACK: u8 = SYN + ACK;
const RST_ACK: u8 = RST + ACK;
fn main() {
if let Err(e) = run() {
eprintln!("[Error] {:?}", e)
}
}
run は引数処理と process 関数の呼び出し。
fn run() -> Result<(), MyError> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
return Ok(eprintln!("Usage: {} interface ip:port", args[0]));
}
if args.len() < 3 {
// 引数不足でエラー
return Err(MyError::TooFewArgumentError);
}
let if_name = &args[1];
let target: &str = &args[2];
let flags = process(if_name.to_string(), target)?;
// 結果を表示して終了
Ok(print_result(target, flags))
}
process は指定されたインターフェースで、ターゲットに対して SYN パケットを送信し応答パケットを受信する関数です。
リモートIP・リモートポート・ローカルIP・ローカルポート・フラグ・シーケンス番号などを特定し、send_and_receive 関数を読みます。
返り値は応答パケットのフラグ。
fn process(if_name: String, target: &str) -> Result<u8, MyError> {
// 接続先IPアドレスとポート番号の特定
let (ri, rp) = split_ip_and_port(target)?;
// 保持するインターフェースの中から該当するものを見つける
let result = interfaces()
.into_iter()
.find(|interface| interface.name == if_name);
// 該当するインターフェースがないときはエラー
if result.is_none() {
return Err(MyError::UnknownInterfaceError);
}
let interface = result.unwrap();
println!("Interface: {}", interface.name);
// ローカルIPをインターフェースより特定
let mut li: Option<Ipv4Addr> = None;
for ip in &interface.ips {
if let IpNetwork::V4(v4) = ip {
li = Some(v4.ip());
break;
}
}
if li.is_none() {
return Err(MyError::LocalIpError);
}
let li = li.unwrap();
let mut rng = rand::thread_rng();
// ローカルポートは Linux のエフェメラルポートの範囲の乱数を指定
let lp = rng.gen_range(32768..=60999);
// フラグは SYN を指定
let flags = SYN;
// シーケンス番号は乱数を指定
let seq_no: u32 = rand::random();
// 応答番号は0を指定
let ack_no: u32 = 0;
send_and_recv(interface, lp, rp, li, ri, flags, seq_no, ack_no)
}
send_and_recv は実際にパケットを送受信する関数です。
ターゲットからの応答パケットを受信する recv 関数のスレッドの用意、SYN パケットの送出。
SYN パケットを送信してからシーケンシャルに受信しようとすると、間に合わず応答パケットを受信できないケースがあったため、受信と送信をマルチスレッドで並行に処理しています。
返り値は応答パケットのフラグ。
fn send_and_recv (
interface: NetworkInterface,
lp: u16, // ローカルポート
rp: u16, // リモートポート
li: Ipv4Addr, // ローカルIP
ri: Ipv4Addr, // リモートIP
flags: u8,
seq_no: u32,
ack_no: u32,
) -> Result<u8, MyError> {
let (mut tx, _) = transport_channel(4096, Layer4(Ipv4(Tcp)))?;
let mut buff: [u8; 24] = [0; 24]; // TCP ヘッダサイズ=6*4バイト
let p = tcp_hdr(&mut buff, lp, rp, li, ri, flags, seq_no, ack_no)?;
// スレッド間の通信用チャネル
let (t, r) = mpsc::channel();
// ターゲットからの応答パケットを受信するスレッドの開始。
// 受信するとチャネル t に応答パケットのフラグを送信する
let handle = spawn(move || {
t.send(match recv(interface, lp, rp, li, ri) {
Err(e) => {
eprintln!("[Error] {:?}", e);
0u8
}
Ok(flags) => flags,
})
.unwrap();
});
// [NOTE] SYNパケット送信を若干待機させる。
// この待機時間が短すぎると受信スレッドの準備が間に合わず、
// 応答パケットを受信できないおそれがある
sleep(Duration::from_millis(100));
// SYN パケットの送信
tx.send_to(p, IpAddr::V4(ri))?;
// SYN パケットの表示
print_tcp_hdr("[Sent]", lp, rp, li, ri, flags, seq_no, ack_no, 1000);
// 受信スレッドから受信した応答パケットのフラグをチャネル r 経由で取得。
// タイムアウト時間 TIME_OUT_MS は必要に応じてチューニングすること
Ok(match r.recv_timeout(Duration::from_millis(TIME_OUT_MS)) {
Err(_) => 0,
Ok(f) => {
handle.join().unwrap();
f
}
})
}
tcp_hdr は必要なパラメータを持つ TCPヘッダを作成する関数です。
フラグ、ポートの設定、チェックサムの計算など。
チェックサムのところ、APIが用意されていて自前で実装しなくてホッとしたのですが、APIの使い方がよくわからずググりまくりました。
fn tcp_hdr(
buff: &mut [u8],
lp: u16,
rp: u16,
li: Ipv4Addr,
ri: Ipv4Addr,
flags: u8,
seq_no: u32,
ack_no: u32,
) -> Result<MutableTcpPacket, MyError> {
let pkt = MutableTcpPacket::new(buff);
if pkt.is_none() {
return Err(MyError::MutableTcpPacketError);
}
let mut pkt = pkt.unwrap();
pkt.set_flags(flags);
pkt.set_source(lp);
pkt.set_destination(rp);
pkt.set_window(1000); // 0 でもよかったかもしれない
pkt.set_data_offset(6);
pkt.set_sequence(seq_no);
pkt.set_acknowledgement(ack_no);
pkt.set_options(&[TcpOption::mss(1460)]); // The maximum segment size (MSS)
pkt.set_checksum(ipv4_checksum(&pkt.to_immutable(), &li, &ri));
Ok(pkt)
}
recv はターゲットからの応答パケットを待ち受ける関数です。返り値は応答パケットのフラグとなっています。
ループで繰り返しイーサネットフレームを読みだしていき、必要な応答パケットを検出したらループを抜けます。
fn recv(
interface: NetworkInterface,
lp: u16,
rp: u16,
li: Ipv4Addr,
ri: Ipv4Addr,
) -> Result<u8, MyError> {
let mut flags = 0u8;
// イーサネットフレームを受信するチャネル rx を取得
if let Ethernet(_, mut rx) = channel(&interface, Default::default())? {
loop {
// rx からイーサネットフレームを取り出して処理する
let bytes = rx.next()?;
let frame = EthernetPacket::new(bytes);
if frame.is_none() {
continue;
}
let frame = frame.unwrap();
if frame.get_ethertype() != EtherTypes::Ipv4 {
continue;
}
let ipv4 = Ipv4Packet::new(frame.payload());
if ipv4.is_none() {
continue;
}
let ipv4 = ipv4.unwrap();
if ipv4.get_source() != ri || ipv4.get_destination() != li {
// IPアドレスが条件を満たさないのでスキップ
continue;
}
if ipv4.get_next_level_protocol() != Tcp {
continue;
}
let tcp = TcpPacket::new(ipv4.payload());
if tcp.is_none() {
continue;
}
let tcp = tcp.unwrap();
if tcp.get_source() != rp || tcp.get_destination() != lp {
// ポート番号が条件を満たさないのでスキップ
continue;
}
// ここまで到達するのは欲しい TCP セグメントだった時
flags = tcp.get_flags();
let seq_no = tcp.get_sequence();
let ack_no = tcp.get_acknowledgement();
let window = tcp.get_window();
print_tcp_hdr("[Received]", lp, rp, li, ri, flags, seq_no, ack_no, window);
break; // ループを抜ける
}
}
Ok(flags)
}
以降は結果表示などの補助的な関数と複数のエラーを統合するエラー型の定義です。(エラーメッセージやる気ねー)
本質的な部分ではないので適当に読み飛ばして下さい。
// 結果の表示
fn print_result(target: &str, flags: u8) {
println!("[Result]");
println!(
"{}: {}",
target,
MSGS[match flags {
SYN_ACK => 0,
RST_ACK => 1,
0 => 2,
_ => 3,
}]
);
}
// TCP ヘッダの表示
fn print_tcp_hdr(
tag: &str,
lp: u16,
rp: u16,
li: Ipv4Addr,
ri: Ipv4Addr,
flags: u8,
seq_no: u32,
ack_no: u32,
window: u16,
) {
println!("{}", tag);
println!("Local: {}:{}", li, lp);
println!("Remote: {}:{}", ri, rp);
println!("Flags: {}", conv_flags(flags));
println!("Seq No: {}", seq_no);
println!("Ack No: {}", ack_no);
println!("Window: {}", window);
}
// TCPフラグを文字列化する関数
fn conv_flags(flags: u8) -> String {
let mut r: Vec<&str> = vec![];
if flags & SYN != 0 {
r.push("SYN");
}
if flags & ACK != 0 {
r.push("ACK");
}
if flags & RST != 0 {
r.push("RST");
}
if flags & FIN != 0 {
r.push("FIN");
}
if flags & PSH != 0 {
r.push("PSH");
}
if flags & CWR != 0 {
r.push("CWR");
}
if flags & ECE != 0 {
r.push("ECE");
}
if flags & URG != 0 {
r.push("URG");
}
r.join(",").to_string()
}
// 文字列 "ip:port" からIPアドレスとポート番号を取得する関数
fn split_ip_and_port(s: &str) -> Result<(Ipv4Addr, u16), MyError> {
let a: Vec<&str> = s.split(':').collect();
if a.len() != 2 {
return Err(MyError::TargetError);
}
if let Ok(ip) = Ipv4Addr::from_str(a[0]) {
if let Ok(port) = a[1].parse() {
return Ok((ip, port));
}
}
Err(MyError::TargetError)
}
#[derive(thiserror::Error, Debug)]
enum MyError {
#[error("IOError({0})")]
IOError(#[from] std::io::Error),
#[error("the provided buffer is less than the minimum required packet size")]
MutableTcpPacketError,
#[error("Too Few Argument")]
TooFewArgumentError,
#[error("TargetError")]
TargetError,
#[error("Unknown Interface")]
UnknownInterfaceError,
#[error("valid IPv4 srcIP is not found ")]
LocalIpError,
#[error("TimeOutError")]
TimeOutError(#[from] std::sync::mpsc::RecvTimeoutError),
#[error("RecvError")]
RecvError(#[from] std::sync::mpsc::RecvError),
}
ビルド手順等
(1) プロジェクトの作成
% cargo new stealth
(2) main.rs の編集
% cd stealth
% nano src/main.rs
上のコードを main.rs にコピペ。
(3) コンパイル
% cargo add pnet rand thiserror
% cargo build --release
target/release/stealth に実行ファイルが保存される。
感想
-
今回のテーマは、RAW Socket の扱い、パケット・スニフィング、並行処理、など、Rust によるシステムプログラミングの一端を学ぶことができて楽しかったです。
-
Rust は今年の 1 月から意識的に使い始めて、7 か月経過しました。
この頃はようやく Rust コードを違和感なく読めるようになってきました。 -
他方で Go コードが書けなくなってしまいました。
以上。