mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-01 18:00:00 -07:00
This fixes several space-related issues at once. We have observed the following phenomenon on TP-Link, Orbic and Moxee: - Filling /data bricks the device (broken wifi, broken rndis, broken display) - Filling /cache does not (it only bricks rayhunter if it's installed there, and it might break firmware updates) Therefore it would make sense to store the entire rayhunter installation in /cache. This is a great idea for TP-Link and Moxee, because /cache is significantly larger than /data. However, on Orbic, /data is significantly larger than /cache! This PR refactors orbic-network and tplink to use a shared codepath for setting up the data directory. A symlink is created at /data/rayhunter, and what it points to is device-specific: - Orbic will have its data at `/data/rayhunter-data` - There is a new alias `installer moxee` that overrides this to `/cache/rayhunter-data` - TP-Link will have its data at /cache/rayhunter-data when there's no SD card, and /media/whatever when there is one. In all cases, existing data is migrated to the new location. The user can switch back and forth between two values of --data-dir and the data will be moved over every time. This PR has one huge wart, and that is that the USB installer for Orbic remains untouched. The annoying reason for this is that the DeviceConnection trait is insufficient to reflect all the different kinds of shells you can have over USB: adb with fakeroot, and serial with real root. I think it's not possible to create the right directories with 'rootshell -c'. I'm thinking of spawning a telnet server over serial, so that we can just do telnet again, but this is for another time.
329 lines
10 KiB
Rust
329 lines
10 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> {
|
|
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(Duration::from_secs(10), 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
|
|
.context("command timed out after 10 seconds")?;
|
|
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} ... ");
|
|
let nc_output = {
|
|
let filename = filename.to_owned();
|
|
let handle = tokio::spawn(async move {
|
|
telnet_send_command_with_output(
|
|
addr,
|
|
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
|
|
wait_for_prompt,
|
|
)
|
|
.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()
|
|
);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|