Rust小项目:用Rust写一个端口扫描器
Rust作为一门通用编程语言,系统级的编程语言写个端口扫描器并不是太复杂,所以本文也会将太多的精力放在怎么扫描上,而是更多的时间放在如何利用Rust快速的扫描和探测端口,即尽可能高并发的完成这个任务,本文从单线程到多线程,最后到异步,算是一个练手项目吧。
如果需要直接可用的端口扫描器可使用王者项目nmap,或者由Rust写的RustScan(超级超级快….)
快速入门
Rust有很多优点, **RAII(Resource Acquisition Is Initialization, 资源获取即初始化, 非常奇怪的名字)**是其中一个优点,它让我们不需要显示的创建和关闭资源,资源在获取的时候就自动初始化了,离开作用域的时候就跟普通生命周期的变量一样被回收了,包括其持有的资源。
关于RAII是啥可以参考这篇文章:https://rustmagazine.github.io/rust_magazine_2021/chapter_4/rust-to-system-essence-raii.html
use std::net::TcpStream;
fn main() {
// 注意: rust会解析域名的.
let target = "baidu.com:443";
match TcpStream::connect(target) {
Ok(_) => {
println!("连接成功");
},
Err(err) => {
println!("连接失败: {err:?}");
}
}
}
上面的代码并不复杂, 构造一个目标地址(域名/IP+端口即可), 然后使用connect
方法连接即可。
有可能有人会认为这段代码存在内存泄露的风险,即socket
没有被回收(这里socket是指变量所持有的资源),但,其实不会,因为socket
跟变量一样绑定了生命周期,当走出作用域的时候就会被回收,这样就不用时刻担心资源的回收问题了,Python是通过with语句
解决这个问题, Golang是通过defer语句
解决这个问题,毫无疑问,Rust要优雅一点。
超时
无论如何,我们应该总是注意超时的问题, 不然的话,很多时间就花在了无意义的等待上,比如服务端将你的数据包直接丢弃而不做任何响应,又或者网络故障,所以除了connect
方法, TcpStream
还提供一个connect_timeout
方法。
use std::time::Duration;
use std::net::{TcpStream, ToSocketAddrs};
fn main() {
// 注意: rust会解析域名的.
let target = "baidu.com:443";
let target = match target.to_socket_addrs() {
Ok(mut addrs) => {
addrs.next().expect("没有传入有效的目标地址.")
},
Err(err) => {
panic!("解析目标地址失败: {err:?}")
}
};
let timeout = Duration::from_secs(1);
match TcpStream::connect_timeout(&target, timeout) {
Ok(_) => {
println!("连接成功");
},
Err(err) => {
println!("连接失败: {err:?}");
}
}
}
由于connect_timeout
的参数签名和connect
方法不一样,所以我们需要手动的解析目标地址,如果只是IP:Port
这种形式,可以使用parse
函数得到SocketAddr
,但是域名:Port
这种形式就需要解析域名得到IP,直接parse
会出错,而标准库里的resolve_socket_addr
又没有暴露出来,所以这里显示的调用ToSocketAddrs trait
的to_socket_addrs
方法用于解析域名。
多线程
一般来说,我们不会探测一个目标, 会有多个端口或者多个IP, 那么我需要增加并发的能力,并发有很多方案,多线程是一个。
use std::thread;
use std::time::Duration;
use std::net::{TcpStream, ToSocketAddrs};
fn connect(target: &str) {
let addr = match target.to_socket_addrs() {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
addr
} else {
println!("[{target}]不是一个有效的目标地址.");
return
}
},
Err(err) => {
println!("[{target}]解析目标地址失败: {err:?}");
return
}
};
let timeout = Duration::from_secs(1);
match TcpStream::connect_timeout(&addr, timeout) {
Ok(_) => {
println!("[{target}]连接成功");
},
Err(err) => {
println!("[{target}]连接失败: {err:?}");
}
}
}
fn main() {
// 注意: rust会解析域名的.
let targets = vec!["baidu.com:443", "bilibili.com:443"];
let handles: Vec<_> = targets.into_iter()
.map(|target| {
thread::spawn(|| {
connect(target)
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
}
上面的代码没有太多令人奇怪的地方,除了使用thread::spawn
为每个连接创建一个线程外和等待线程执行完成外没有太多改动的地方。
异步
异步运行时主要由两大阵营tokio
和async-std
, 前文提到的RustScan
使用的是后者,不知道为啥, 这里就用tokio
演示吧。
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::timeout as connect_with_timeout;
async fn tcp_connect(target: &str) {
match TcpStream::connect(target).await {
Ok(_) => {
println!("[{target}]连接成功");
},
Err(err) => {
println!("[{target}]连接失败: {err:?}");
}
}
}
async fn connect(target: &str, timeout: Duration) {
if let Err(_) = connect_with_timeout(timeout, tcp_connect(target)).await {
println!("[{target}]连接超时");
}
}
#[tokio::main]
async fn main() {
let targets = vec!["baidu.com:443", "bilibili.com:443"];
let handles: Vec<_> = targets.into_iter()
.map(|target| {
tokio::spawn(async {
let timeout = Duration::from_secs(1);
connect(target, timeout).await
})
})
.collect();
for handle in handles {
handle.await.unwrap()
}
}
与标准库不同点在于tokio
没有connect_timeout
方法,取而代之的一个timeout
的帮助函数,通过这个函数可以设置超时时间,有意思的是,它只关心是否超时,不关心是否抛出了错误(即不会传播错误),无论传入的future
返回成功或者失败都被当做没有超时,而没有超时对于timeout
而言就是成功, 所以上面的代码额外的包装了一下TcpStream::connect
方法。
半连接扫描(探测)
众所周知的是,TCP需要三次握手,即
- 客户端: 你好,咱们握个手呗,我是小王
- 服务端: 好的,我是大王,我同意和小王你握手
- 客户端: 好的好的,我就是要跟你握手的小王
从上面流程,我们可以发现,服务端在完成第二步之后,我们就能知道对方是否监听服务了,所以为了更快,我们可以不完成第三步,理论上,至少快了个1/3吧, 但是吧,这样不道德,所以合法的探测自己的服务的时候,我们一般是在第三步发送一个RST
的响应,这样可以避免对方重试,也能双方不建立连接。
怎么实现呢?可以使用pnet
, 具体代码可以参考这篇文章: https://dev.to/sighgone/building-a-syn-scanner-in-rust-gcm, 我试过了,但是失败了,所以没有在本文粘贴代码,不知道为啥服务端不响应我的请求T_T。
总结
端口扫描器在安全领域有一定的作用,在负载均衡等技术中也有些一些用,前者是为了发现可攻击的点,后者是为了探测服务是否可用,总得来说,它有些作用值得写来玩玩。