feat: support Tmobile TMOHS1 hotspot

Add support for the Tmobile TMOHS1, a Wingtech CT2MHS01-based hotspot
with a Qualcomm mdm9607. The TMOHS1 has no screen, only 5 LEDs, two of
which are RGB.
This commit is contained in:
oopsbagel
2025-06-12 20:51:59 -07:00
committed by Cooper Quintin
parent f23cc07652
commit b7636386fc
7 changed files with 307 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ edition = "2024"
[features]
# These feature flags are mutually exclusive, and exactly one must be enabled.
orbic = ["rayhunter/orbic"]
tmobile = ["rayhunter/tmobile"]
tplink = ["rayhunter/tplink"]
wingtech = ["rayhunter/wingtech"]

View File

@@ -1,5 +1,11 @@
#[cfg(any(feature = "orbic", feature = "tplink", feature = "wingtech"))]
mod generic_framebuffer;
#[cfg(feature = "tmobile")]
mod tmobile;
#[cfg(feature = "tmobile")]
pub use tmobile::update_ui;
#[cfg(feature = "tplink")]
mod tplink;
#[cfg(feature = "tplink")]
@@ -20,6 +26,7 @@ mod wingtech;
#[cfg(feature = "wingtech")]
pub use wingtech::update_ui;
#[derive(Clone, Copy, PartialEq)]
pub enum DisplayState {
Recording,
Paused,

View File

@@ -0,0 +1,85 @@
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
/// DisplayState::Recording => Signal LED slowly blinks blue.
/// DisplayState::Paused => WiFi LED blinks white.
/// DisplayState::WarningDetected => Signal LED slowly blinks red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use std::fs::write;
use std::thread::sleep;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{
format!("/sys/class/leds/led:{}/blink", $l)
}};
}
fn start_blinking(path: String) {
write(&path, "1").ok();
}
fn stop_blinking(path: String) {
write(&path, "0").ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn_blocking(move || {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
if invisible || state == last_state {
sleep(Duration::from_secs(1));
continue;
}
match state {
DisplayState::Paused => {
stop_blinking(led!("signal_blue"));
stop_blinking(led!("signal_red"));
start_blinking(led!("wlan_white"));
}
DisplayState::Recording => {
stop_blinking(led!("wlan_white"));
stop_blinking(led!("signal_red"));
start_blinking(led!("signal_blue"));
}
DisplayState::WarningDetected => {
stop_blinking(led!("wlan_white"));
stop_blinking(led!("signal_blue"));
start_blinking(led!("signal_red"));
}
}
last_state = state;
sleep(Duration::from_secs(1));
}
});
}

View File

@@ -14,6 +14,11 @@ fn main() {
"FILE_RAYHUNTER_DAEMON_ORBIC",
"rayhunter-daemon",
);
set_binary_var(
include_dir,
"FILE_RAYHUNTER_DAEMON_TMOBILE",
"rayhunter-daemon",
);
set_binary_var(
include_dir,
"FILE_RAYHUNTER_DAEMON_TPLINK",

View File

@@ -3,6 +3,7 @@ use clap::{Parser, Subcommand};
use env_logger::Env;
mod orbic;
mod tmobile;
mod tplink;
mod util;
mod wingtech;
@@ -21,6 +22,8 @@ struct Args {
enum Command {
/// Install rayhunter on the Orbic Orbic RC400L.
Orbic(InstallOrbic),
/// Install rayhunter on the TMobile TMOHS1.
Tmobile(TmobileArgs),
/// Install rayhunter on the TP-Link M7350.
Tplink(InstallTpLink),
/// Install rayhunter on the Wingtech CT2MHS01.
@@ -67,6 +70,10 @@ enum UtilSubCommand {
Serial(Serial),
/// Start an ADB shell
Shell(Shell),
/// Root the Tmobile and launch adb.
TmobileStartAdb(TmobileArgs),
/// Root the Tmobile and launch telnetd.
TmobileStartTelnet(TmobileArgs),
/// Root the tplink and launch telnetd.
TplinkStartTelnet(TplinkStartTelnet),
/// Root the Wingtech and launch telnetd.
@@ -85,6 +92,17 @@ enum UtilSubCommand {
WingtechSendFile(WingtechSendFile),
}
#[derive(Parser, Debug)]
struct TmobileArgs {
/// IP address for Tmobile admin interface, if custom.
#[arg(long, default_value = "192.168.0.1")]
admin_ip: String,
/// Web portal admin password.
#[arg(long)]
admin_password: String,
}
#[derive(Parser, Debug)]
struct TplinkStartTelnet {
/// IP address for TP-Link admin interface, if custom.
@@ -140,6 +158,7 @@ async fn run() -> Result<(), Error> {
let Args { command } = Args::parse();
match command {
Command::Tmobile(args) => tmobile::install(args).await.context("Failed to install rayhunter on the Tmobile TMOHS1. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
@@ -163,6 +182,8 @@ async fn run() -> Result<(), Error> {
}
}
UtilSubCommand::Shell(_) => orbic::shell().await.context("\nFailed to open shell on Orbic RC400L")?,
UtilSubCommand::TmobileStartTelnet(args) => tmobile::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Tmobile TMOHS1")?,
UtilSubCommand::TmobileStartAdb(args) => tmobile::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Tmobile TMOHS1")?,
UtilSubCommand::TplinkStartTelnet(options) => {
tplink::start_telnet(&options.admin_ip).await?;
}

187
installer/src/tmobile.rs Normal file
View File

@@ -0,0 +1,187 @@
/// Installer for the TMobile TMOHS1 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89527AA1_V045_M11_TMO_USR_MP
/// WT_PRODUCTION_VERSION=TMOHS1_00.05.20
/// WT_HARDWARE_VERSION=89527_1_11
use std::io::Write;
use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
use anyhow::{Result, bail};
use base64_light::base64_encode_bytes;
use block_padding::{Padding, Pkcs7};
use reqwest::Client;
use tokio::time::sleep;
use crate::TmobileArgs as Args;
use crate::util::{echo, telnet_send_command, telnet_send_file};
pub async fn install(
Args {
admin_ip,
admin_password,
}: Args,
) -> Result<()> {
run_install(admin_ip, admin_password).await
}
const KEY: &[u8] = b"abcdefghijklmn12";
/// Returns password encrypted in AES128 ECB mode with the key b"abcdefghijklmn12",
/// with Pkcs7 padding, encoded in base64.
fn encrypt_password(password: &[u8]) -> Result<String> {
let c = Aes128::new_from_slice(KEY)?;
let mut b = GenericArray::from([0u8; 16]);
b[..password.len()].copy_from_slice(password);
Pkcs7::pad(&mut b, password.len());
c.encrypt_block(&mut b);
Ok(base64_encode_bytes(&b))
}
pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result<()> {
run_command(admin_ip, admin_password, "busybox telnetd -l /bin/sh").await
}
pub async fn start_adb(admin_ip: &str, admin_password: &str) -> Result<()> {
run_command(admin_ip, admin_password, "/sbin/usb/compositions/9025").await
}
async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result<()> {
let qcmap_auth_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_auth");
let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi");
let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap();
let client = Client::new();
let login = client
.post(&qcmap_auth_endpoint)
.body(format!(
"type=login&pwd={encrypted_pw}&timeout=60000&user=admin"
))
.send()
.await?
.text()
.await?;
let token = match login.find("token") {
Some(n) => &login[n + 8..n + 8 + 16],
None => bail!("login did not return a token in response: {}", login),
};
let telnet = client.post(&qcmap_web_cgi_endpoint)
.body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}"))
.send()
.await?;
if telnet.status() != 200 {
bail!(
"starting telnet failed with status code: {:?}",
telnet.status()
);
}
Ok(())
}
async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
echo!("Starting telnet ... ");
start_telnet(&admin_ip, &admin_password).await?;
println!("ok");
echo!("Connecting via telnet to {admin_ip} ... ");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
println!("ok");
telnet_send_command(addr, "mount -o remount,rw /", "exit code 0").await?;
telnet_send_file(
addr,
"/data/rayhunter/config.toml",
crate::CONFIG_TOML.as_bytes(),
)
.await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_TMOBILE"));
telnet_send_file(
addr,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /data/rayhunter/rayhunter-daemon",
"exit code 0",
)
.await?;
telnet_send_file(
addr,
"/etc/init.d/misc-daemon",
include_bytes!("../../dist/scripts/misc-daemon"),
)
.await?;
telnet_send_command(addr, "chmod 755 /etc/init.d/misc-daemon", "exit code 0").await?;
telnet_send_file(
addr,
"/etc/init.d/rayhunter_daemon",
crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0",
)
.await?;
println!("Rebooting device and waiting 30 seconds for it to start up.");
telnet_send_command(addr, "reboot", "exit code 0").await?;
sleep(Duration::from_secs(30)).await;
echo!("Testing rayhunter ... ");
let max_failures = 10;
http_ok_every(
format!("http://{admin_ip}:8080/index.html"),
Duration::from_secs(3),
max_failures,
)
.await?;
println!("ok");
println!("rayhunter is running at http://{admin_ip}:8080");
Ok(())
}
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(())
}
#[test]
fn test_encrypt_password() {
let p = b"80536913";
let s = encrypt_password(p).ok();
let expected = Some("5brvd8xl732cSoFTAy67ig==".to_string());
assert_eq!(s, expected);
}

View File

@@ -12,6 +12,7 @@ path = "src/lib.rs"
[features]
default = []
orbic = []
tmobile = []
tplink = []
wingtech = []