mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-11 07:13:30 -07:00
344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
use std::net::SocketAddr;
|
|
use std::str::FromStr;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use nusb::Device;
|
|
use reqwest::Client;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
use tokio::time::{sleep, timeout};
|
|
|
|
use crate::output::{print, println};
|
|
|
|
#[cfg(unix)]
|
|
use std::os::fd::AsRawFd;
|
|
|
|
pub async fn telnet_send_command_with_output(
|
|
addr: SocketAddr,
|
|
command: &str,
|
|
wait_for_prompt: bool,
|
|
) -> Result<String> {
|
|
telnet_send_command_with_timeout(addr, command, wait_for_prompt, Duration::from_secs(10)).await
|
|
}
|
|
|
|
async fn telnet_send_command_with_timeout(
|
|
addr: SocketAddr,
|
|
command: &str,
|
|
wait_for_prompt: bool,
|
|
command_timeout: Duration,
|
|
) -> Result<String> {
|
|
if command.contains('\n') {
|
|
bail!("multi-line commands are not allowed");
|
|
}
|
|
|
|
let stream = TcpStream::connect(addr).await?;
|
|
let (mut reader, mut writer) = stream.into_split();
|
|
|
|
if wait_for_prompt {
|
|
// Wait for the shell prompt. This also consumes any telnet IAC negotiation
|
|
// the server sends at connection start, and ensures the shell is ready
|
|
// for input.
|
|
while reader.read_u8().await? != b'#' {}
|
|
}
|
|
|
|
// This contraption is there so we clearly know where the command output starts and ends,
|
|
// skipping telnet echoing the command back using START, and terminating the connection right
|
|
// after the command exits.
|
|
//
|
|
// 'TELNET' is quoted so that when the command gets echoed back, it does not match against
|
|
// RAYHUNTER_TELNET_COMMAND_DONE search string.
|
|
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
|
|
|
|
let mut read_buf = Vec::new();
|
|
timeout(command_timeout, async {
|
|
loop {
|
|
let Ok(byte) = reader.read_u8().await else {
|
|
break;
|
|
};
|
|
read_buf.push(byte);
|
|
|
|
// when we see this string we know the command is done and can terminate.
|
|
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
|
|
// may not terminate the connection appropriately on their own.
|
|
if byte == b'\n' {
|
|
let response = String::from_utf8_lossy(&read_buf);
|
|
if response.contains("RAYHUNTER_TELNET_COMMAND_DONE") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.with_context(|| format!("command timed out after {}s", command_timeout.as_secs()))?;
|
|
let string = String::from_utf8_lossy(&read_buf);
|
|
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
|
|
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
|
|
let string = match (start, end) {
|
|
(Some(start), Some(end)) => {
|
|
// skip past the START marker and the trailing \r\n of the echoed command line
|
|
let start = start + "RAYHUNTER_TELNET_COMMAND_START".len();
|
|
string[start..end].trim_start_matches(['\r', '\n'])
|
|
}
|
|
_ => bail!("failed to parse command output from string: {string:?}"),
|
|
};
|
|
Ok(string.to_string())
|
|
}
|
|
|
|
pub async fn telnet_send_command(
|
|
addr: SocketAddr,
|
|
command: &str,
|
|
expected_output: &str,
|
|
wait_for_prompt: bool,
|
|
) -> Result<()> {
|
|
let command = format!("{command}; echo command done, exit code $?");
|
|
let output = telnet_send_command_with_output(addr, &command, wait_for_prompt).await?;
|
|
if !output.contains(expected_output) {
|
|
bail!("{expected_output:?} not found in: {output}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn telnet_send_file(
|
|
addr: SocketAddr,
|
|
filename: &str,
|
|
payload: &[u8],
|
|
wait_for_prompt: bool,
|
|
) -> Result<()> {
|
|
print!("Sending file {filename} ... ");
|
|
// Allow 30s base + 2s per MB for the nc command to complete (covers slow WiFi links)
|
|
let transfer_timeout =
|
|
Duration::from_secs(30 + (payload.len() as u64 / (512 * 1024)).max(1) * 2);
|
|
let nc_output = {
|
|
let filename = filename.to_owned();
|
|
let handle = tokio::spawn(async move {
|
|
telnet_send_command_with_timeout(
|
|
addr,
|
|
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
|
|
wait_for_prompt,
|
|
transfer_timeout,
|
|
)
|
|
.await
|
|
});
|
|
|
|
let mut addr = addr;
|
|
addr.set_port(8081);
|
|
|
|
let mut stream;
|
|
let mut attempts = 0;
|
|
|
|
loop {
|
|
// wait for nc to become available, with exponential backoff.
|
|
//
|
|
// if the installer fails with connection refused, this
|
|
// likely is not high enough.
|
|
sleep(Duration::from_millis(100 * (1 << attempts))).await;
|
|
|
|
stream = TcpStream::connect(addr).await;
|
|
attempts += 1;
|
|
if stream.is_ok() || attempts > 3 {
|
|
break;
|
|
}
|
|
|
|
print!("attempt {attempts}... ");
|
|
}
|
|
|
|
let send_result: Result<()> = async {
|
|
let mut stream = stream?;
|
|
stream.write_all(payload).await?;
|
|
|
|
// if the orbic is sluggish, we need for nc to write the data to disk before
|
|
// terminating the connection. if we terminate the connection while there is unflushed
|
|
// data, that data will just not be written from nc's buffer into OS disk buffer. the
|
|
// symptom is mismatched md5 hashes.
|
|
//
|
|
// this is NOT fixed by calling fsync or similar, we're talking about dropped
|
|
// application buffers here.
|
|
sleep(Duration::from_millis(1000)).await;
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
let nc_output = handle
|
|
.await
|
|
.context("background nc writer failed")?
|
|
.context("background nc writer failed")?;
|
|
|
|
if let Err(e) = send_result {
|
|
bail!(
|
|
"Failed to send data to nc: {e}. nc output: '{}'",
|
|
nc_output.trim()
|
|
);
|
|
}
|
|
|
|
nc_output
|
|
};
|
|
|
|
let checksum = md5::compute(payload);
|
|
telnet_send_command(
|
|
addr,
|
|
&format!("md5sum {filename}.tmp"),
|
|
&format!("{checksum:x} {filename}.tmp"),
|
|
wait_for_prompt,
|
|
)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"File transfer failed. nc command output: '{}'. Expected checksum: {:x}",
|
|
nc_output.trim(),
|
|
checksum
|
|
)
|
|
})?;
|
|
|
|
telnet_send_command(
|
|
addr,
|
|
&format!("mv {filename}.tmp {filename}"),
|
|
"exit code 0",
|
|
wait_for_prompt,
|
|
)
|
|
.await?;
|
|
|
|
println!("ok");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn send_file(admin_ip: &str, local_path: &str, remote_path: &str) -> Result<()> {
|
|
let file_content = std::fs::read(local_path)
|
|
.with_context(|| format!("Failed to read local file: {local_path}"))?;
|
|
|
|
println!("Connecting to {admin_ip}");
|
|
let addr = SocketAddr::from_str(&format!("{admin_ip}:23"))
|
|
.with_context(|| format!("Invalid IP address: {admin_ip}"))?;
|
|
|
|
telnet_send_file(addr, remote_path, &file_content, true)
|
|
.await
|
|
.with_context(|| format!("Failed to send file {local_path} to {remote_path}"))?;
|
|
|
|
println!("Successfully sent {local_path} to {remote_path}");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn http_ok_every(
|
|
rayhunter_url: String,
|
|
interval: Duration,
|
|
max_failures: u32,
|
|
) -> Result<()> {
|
|
let client = Client::new();
|
|
let mut failures = 0;
|
|
loop {
|
|
match client.get(&rayhunter_url).send().await {
|
|
Ok(test) => match test.status().is_success() {
|
|
true => break,
|
|
false => bail!(
|
|
"request for url ({rayhunter_url}) failed with status code: {:?}",
|
|
test.status()
|
|
),
|
|
},
|
|
Err(e) => match failures > max_failures {
|
|
true => return Err(e.into()),
|
|
false => failures += 1,
|
|
},
|
|
}
|
|
sleep(interval).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// General function to open a USB device
|
|
#[cfg(not(target_os = "android"))]
|
|
pub fn open_usb_device(vid: u16, pid: u16) -> Result<Option<Device>> {
|
|
let devices = match nusb::list_devices() {
|
|
Ok(d) => d,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
for device in devices {
|
|
if device.vendor_id() == vid && device.product_id() == pid {
|
|
match device.open() {
|
|
Ok(d) => return Ok(Some(d)),
|
|
Err(e) => bail!("device found but failed to open: {}", e),
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Open an interactive shell to a device
|
|
///
|
|
/// Connects to a shell service on the device and forwards stdin/stdout bidirectionally.
|
|
pub async fn interactive_shell(admin_ip: &str, shell_port: u16, raw_mode: bool) -> Result<()> {
|
|
let shell_addr = SocketAddr::from_str(&format!("{admin_ip}:{shell_port}"))?;
|
|
let mut stream = TcpStream::connect(shell_addr)
|
|
.await
|
|
.context("Failed to connect to shell. Make sure the device is reachable.")?;
|
|
|
|
let stdin = tokio::io::stdin();
|
|
|
|
#[cfg(unix)]
|
|
let raw_terminal_guard = if raw_mode {
|
|
Some(RawTerminal::new(stdin.as_raw_fd())?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// suppress "unused variable" lint
|
|
#[cfg(not(unix))]
|
|
let _used = raw_mode;
|
|
|
|
let mut stdio = tokio::io::join(stdin, tokio::io::stdout());
|
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut stdio).await;
|
|
|
|
// hitting ctrl-d will not print a trailing newline on tplink at least, which messes up the
|
|
// next prompt
|
|
println!();
|
|
|
|
// The current_thread runtime in tokio will block forever until stdin receives a read error. To
|
|
// work around this cleanup issue we just exit directly from here.
|
|
//
|
|
// This is documented as a flaw in tokio::io::stdin()'s own docs, but the recommended
|
|
// workaround to spawn your own OS thread doesn't work.
|
|
//
|
|
// For some reason this only happens when the terminal is being put in raw mode (removing
|
|
// RawTerminal fixes it)
|
|
//
|
|
// We have to drop the RawTerminal guard before exiting, otherwise we will
|
|
// mess up the terminal.
|
|
#[cfg(unix)]
|
|
drop(raw_terminal_guard);
|
|
std::process::exit(0)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct RawTerminal {
|
|
fd: std::os::fd::RawFd,
|
|
original_termios: termios::Termios,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl RawTerminal {
|
|
fn new(fd: std::os::fd::RawFd) -> Result<Self> {
|
|
// put terminal in raw mode so that arrow keys, tab etc are correctly forwarded to the
|
|
// device's shell
|
|
let original_termios = termios::Termios::from_fd(fd)?;
|
|
let mut new_termios = original_termios;
|
|
|
|
// set flags on the struct
|
|
termios::cfmakeraw(&mut new_termios);
|
|
|
|
// apply changes
|
|
termios::tcsetattr(fd, termios::TCSANOW, &new_termios)?;
|
|
|
|
Ok(RawTerminal {
|
|
fd,
|
|
original_termios,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for RawTerminal {
|
|
fn drop(&mut self) {
|
|
let _ = termios::tcsetattr(self.fd, termios::TCSANOW, &self.original_termios);
|
|
}
|
|
}
|