mirror of
https://github.com/entropykit/entropia
synced 2026-06-24 06:05:04 +00:00
1986 lines
82 KiB
Rust
1986 lines
82 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
//! dap.rs - Debug Adapter Protocol server speaking on stdio.
|
|
//!
|
|
//! Implements the F5 flow:
|
|
//!
|
|
//! initialize to launch to setBreakpoints* to configurationDone
|
|
//! ↓
|
|
//! tracer thread starts, runs until first int 3
|
|
//! ↓
|
|
//! stopped event + register snapshot + (line, col)
|
|
//! ↓
|
|
//! continue / next / stepInstruction / setBreakpoints to resume to ...
|
|
//! ↓
|
|
//! exited / terminated to disconnect
|
|
//!
|
|
//! Beyond the basic stepping loop, this adapter also exposes:
|
|
//!
|
|
//! - the "Registers" + "Stack" variables scopes (RAX..R15, RIP,
|
|
//! RFLAGS; sixteen qwords above RSP) - answers `scopes` and
|
|
//! `variables`.
|
|
//! - `disassemble`: feeds VS Code's Disassembly View. Reads bytes
|
|
//! out of the live shellcode region and decodes them with
|
|
//! iced-x86. Instruction stepping pairs naturally with this view.
|
|
//! - `readMemory`: lets users punch any address into the Hex Editor
|
|
//! /Memory view. Validated through VirtualQuery so a typo doesn't
|
|
//! crash the adapter.
|
|
//!
|
|
//! Out of scope (returns empty / stub): writeMemory, evaluate,
|
|
//! conditional breakpoints, multiple threads.
|
|
//!
|
|
//! Protocol shape (per DAP spec):
|
|
//!
|
|
//! Content-Length: <n>\r\n
|
|
//! \r\n
|
|
//! <n bytes of JSON>
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io::{self, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicI64, Ordering};
|
|
use std::sync::mpsc;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::sourcemap::SourceMap;
|
|
use crate::tracer::{self, Cmd, Event, StopReason, TracerSession};
|
|
|
|
// ============================================================================
|
|
// Wire framing.
|
|
// ============================================================================
|
|
|
|
fn read_message<R: Read>(rdr: &mut R) -> io::Result<Option<Value>> {
|
|
let mut content_length: Option<usize> = None;
|
|
let mut buf = Vec::<u8>::new();
|
|
loop {
|
|
let mut b = [0u8; 1];
|
|
match rdr.read_exact(&mut b) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
|
Err(e) => return Err(e),
|
|
}
|
|
buf.push(b[0]);
|
|
if buf.ends_with(b"\r\n") {
|
|
let line = &buf[..buf.len() - 2];
|
|
if line.is_empty() { break; }
|
|
if let Some(rest) = strip_header_prefix(line, b"Content-Length:") {
|
|
if let Ok(s) = std::str::from_utf8(rest) {
|
|
content_length = s.trim().parse().ok();
|
|
}
|
|
}
|
|
buf.clear();
|
|
}
|
|
}
|
|
let len = content_length.unwrap_or(0);
|
|
let mut body = vec![0u8; len];
|
|
rdr.read_exact(&mut body)?;
|
|
let v: Value = serde_json::from_slice(&body)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
Ok(Some(v))
|
|
}
|
|
|
|
fn strip_header_prefix<'a>(line: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
|
|
if line.len() < prefix.len() { return None; }
|
|
if line[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
|
Some(&line[prefix.len()..])
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared writer.
|
|
// ============================================================================
|
|
|
|
struct DapWriter {
|
|
out: Mutex<io::Stdout>,
|
|
seq: AtomicI64,
|
|
}
|
|
|
|
impl DapWriter {
|
|
fn new() -> Self {
|
|
Self { out: Mutex::new(io::stdout()), seq: AtomicI64::new(1) }
|
|
}
|
|
fn next_seq(&self) -> i64 { self.seq.fetch_add(1, Ordering::Relaxed) }
|
|
|
|
fn write_frame(&self, body: &[u8]) {
|
|
if let Ok(mut out) = self.out.lock() {
|
|
let _ = write!(out, "Content-Length: {}\r\n\r\n", body.len());
|
|
let _ = out.write_all(body);
|
|
let _ = out.flush();
|
|
}
|
|
}
|
|
fn reply_ok(&self, req: &Value, body: Value) {
|
|
let msg = json!({
|
|
"type": "response",
|
|
"seq": self.next_seq(),
|
|
"request_seq": req["seq"],
|
|
"success": true,
|
|
"command": req["command"],
|
|
"body": body,
|
|
});
|
|
self.write_frame(&serde_json::to_vec(&msg).unwrap());
|
|
}
|
|
fn reply_err(&self, req: &Value, message: &str) {
|
|
let msg = json!({
|
|
"type": "response",
|
|
"seq": self.next_seq(),
|
|
"request_seq": req["seq"],
|
|
"success": false,
|
|
"command": req["command"],
|
|
"message": message,
|
|
});
|
|
self.write_frame(&serde_json::to_vec(&msg).unwrap());
|
|
}
|
|
fn event(&self, name: &str, body: Value) {
|
|
let msg = json!({
|
|
"type": "event",
|
|
"seq": self.next_seq(),
|
|
"event": name,
|
|
"body": body,
|
|
});
|
|
self.write_frame(&serde_json::to_vec(&msg).unwrap());
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Variable reference IDs.
|
|
//
|
|
// VS Code identifies expandable variable lists by integer reference.
|
|
// We reserve a small static set; nothing here is dynamic enough to
|
|
// need a registry.
|
|
// ============================================================================
|
|
|
|
const VARREF_REGISTERS: i64 = 1;
|
|
const VARREF_STACK: i64 = 2;
|
|
const VARREF_LOCALS: i64 = 3;
|
|
|
|
// ============================================================================
|
|
// Server state.
|
|
// ============================================================================
|
|
|
|
struct PendingLaunch {
|
|
bin_path: String,
|
|
stop_on_entry: bool,
|
|
/// What kind of artifact `bin_path` points at:
|
|
/// - `Shellcode` - flat `.bin` produced by `--type=standard`.
|
|
/// Tracer VirtualAllocs RWX + copies + calls offset 0 as
|
|
/// `extern "C" fn() -> i64`.
|
|
/// - `Bof { args }` - `.obj` produced by `--type=bof|coff`.
|
|
/// Loaded via [`bof_loader::load`] (parses COFF, maps
|
|
/// sections, resolves `__imp_*`, applies relocations).
|
|
/// Tracer plants int 3 at debug offsets in `.text` and
|
|
/// calls `go(args, len)` as `extern "system"`.
|
|
artifact: ArtifactKind,
|
|
}
|
|
|
|
enum ArtifactKind {
|
|
Shellcode,
|
|
Bof { args: Vec<u8> },
|
|
}
|
|
|
|
/// Per-breakpoint metadata that affects how a stop is handled:
|
|
///
|
|
/// - `condition` - DAP "expression that must be true for the bp to
|
|
/// fire". We evaluate it on each hit; false to auto-resume.
|
|
/// - `log_message` - DAP "logpoint". On hit, format the template and
|
|
/// emit an Output event, then auto-resume; never
|
|
/// stops. Template `{expr}` placeholders are
|
|
/// evaluated against the registers + locals.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct BpMeta {
|
|
condition: Option<String>,
|
|
log_message: Option<String>,
|
|
}
|
|
|
|
/// State shared between the request-handling loop and the tracer-event
|
|
/// forwarder thread. The forwarder needs to:
|
|
/// (a) read the source map to resolve `line` to variable scope,
|
|
/// (b) read the tracer session to evaluate expressions against live
|
|
/// register state,
|
|
/// (c) look up per-breakpoint metadata to decide whether the hit
|
|
/// should fire / be skipped / be turned into a log,
|
|
/// (d) send `Cmd::Continue` back to the tracer when skipping.
|
|
/// All four arrive at different times relative to when the forwarder
|
|
/// is spawned, so we route them through Mutex<Option<...>> rather
|
|
/// than capturing at spawn time.
|
|
struct SharedDap {
|
|
map: Mutex<Option<SourceMap>>,
|
|
session: Mutex<Option<Arc<TracerSession>>>,
|
|
cmd_tx: Mutex<Option<mpsc::Sender<Cmd>>>,
|
|
bp_meta: Mutex<HashMap<usize, BpMeta>>,
|
|
last_stop: Mutex<Option<(u32, u32)>>,
|
|
}
|
|
|
|
impl SharedDap {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
map: Mutex::new(None),
|
|
session: Mutex::new(None),
|
|
cmd_tx: Mutex::new(None),
|
|
bp_meta: Mutex::new(HashMap::new()),
|
|
last_stop: Mutex::new(None),
|
|
})
|
|
}
|
|
fn map_clone(&self) -> Option<SourceMap> { self.map.lock().unwrap().clone() }
|
|
fn session_clone(&self) -> Option<Arc<TracerSession>> { self.session.lock().unwrap().clone() }
|
|
fn current_line(&self) -> u32 { self.last_stop.lock().unwrap().map(|(l,_)| l).unwrap_or(0) }
|
|
}
|
|
|
|
struct Server {
|
|
pending_launch: Option<PendingLaunch>,
|
|
user_bps: Vec<usize>,
|
|
tracer_join: Option<thread::JoinHandle<()>>,
|
|
shared: Arc<SharedDap>,
|
|
}
|
|
|
|
impl Server {
|
|
fn new(shared: Arc<SharedDap>) -> Self {
|
|
Self {
|
|
pending_launch: None,
|
|
user_bps: Vec::new(),
|
|
tracer_join: None,
|
|
shared,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main loop.
|
|
// ============================================================================
|
|
|
|
pub fn serve() {
|
|
let stdin = io::stdin();
|
|
let mut rdr = stdin.lock();
|
|
let writer = Arc::new(DapWriter::new());
|
|
let shared = SharedDap::new();
|
|
let mut srv = Server::new(shared.clone());
|
|
|
|
let (ev_tx_main, ev_rx_main) = mpsc::channel::<Event>();
|
|
let writer_clone = writer.clone();
|
|
let shared_clone = shared.clone();
|
|
let _ = thread::spawn(move || {
|
|
for ev in ev_rx_main.iter() {
|
|
match ev {
|
|
Event::Stopped { line, col, reason, description, offset, .. } => {
|
|
*shared_clone.last_stop.lock().unwrap() = Some((line, col));
|
|
|
|
// Per-breakpoint metadata: a logpoint hit emits an
|
|
// Output event and auto-resumes (never shows the
|
|
// stop in the UI). A false condition auto-resumes
|
|
// silently. Both paths bypass the stopped event.
|
|
if matches!(reason, StopReason::Breakpoint) {
|
|
let meta = shared_clone.bp_meta.lock().unwrap()
|
|
.get(&offset).cloned().unwrap_or_default();
|
|
if let Some(template) = meta.log_message.as_deref() {
|
|
let rendered = render_log_message(template, &shared_clone, line);
|
|
writer_clone.event("output", json!({
|
|
"category": "console",
|
|
"output": format!("{rendered}\n"),
|
|
"line": line,
|
|
}));
|
|
send_continue(&shared_clone);
|
|
continue;
|
|
}
|
|
if let Some(cond) = meta.condition.as_deref() {
|
|
if !evaluate_truthy(cond, &shared_clone, line) {
|
|
send_continue(&shared_clone);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let reason_str = match reason {
|
|
StopReason::Entry => "entry",
|
|
StopReason::Breakpoint => "breakpoint",
|
|
StopReason::Step => "step",
|
|
StopReason::Pause => "pause",
|
|
StopReason::Exception => "exception",
|
|
};
|
|
let mut body = json!({
|
|
"reason": reason_str,
|
|
"threadId": 1,
|
|
"allThreadsStopped": true,
|
|
"preserveFocusHint": false,
|
|
});
|
|
if let Some(text) = description {
|
|
body["text"] = json!(text);
|
|
body["description"] = json!("debuggee fault");
|
|
}
|
|
writer_clone.event("stopped", body);
|
|
}
|
|
Event::Output { text } => {
|
|
writer_clone.event("output", json!({
|
|
"category": "console",
|
|
"output": format!("{text}\n"),
|
|
}));
|
|
}
|
|
Event::Exited { code } => {
|
|
writer_clone.event("exited", json!({ "exitCode": code }));
|
|
writer_clone.event("terminated", json!({}));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let tracer_ev_tx = ev_tx_main;
|
|
|
|
fn send_continue(shared: &SharedDap) {
|
|
if let Some(tx) = shared.cmd_tx.lock().unwrap().as_ref() {
|
|
let _ = tx.send(Cmd::Continue);
|
|
}
|
|
}
|
|
|
|
loop {
|
|
let req = match read_message(&mut rdr) {
|
|
Ok(Some(v)) => v,
|
|
Ok(None) => return,
|
|
Err(e) => { eprintln!("dap: read error: {e}"); return; }
|
|
};
|
|
let cmd = req["command"].as_str().unwrap_or("").to_string();
|
|
match cmd.as_str() {
|
|
"initialize" => {
|
|
writer.reply_ok(&req, json!({
|
|
"supportsConfigurationDoneRequest": true,
|
|
"supportsTerminateRequest": true,
|
|
"supportsRestartRequest": true,
|
|
"supportsBreakpointLocationsRequest": true,
|
|
"supportsSteppingGranularity": true,
|
|
"supportsDisassembleRequest": true,
|
|
"supportsReadMemoryRequest": true,
|
|
"supportsConditionalBreakpoints": true,
|
|
"supportsLogPoints": true,
|
|
"supportsEvaluateForHovers": true,
|
|
"supportsStepBack": false,
|
|
"supportsExceptionInfoRequest": false,
|
|
}));
|
|
writer.event("initialized", json!({}));
|
|
}
|
|
|
|
"launch" | "attach" => {
|
|
let args = &req["arguments"];
|
|
let program = args["program"].as_str()
|
|
.or_else(|| args["bin"].as_str())
|
|
.map(String::from);
|
|
let Some(program) = program else {
|
|
writer.reply_err(&req,
|
|
"launch: `program` is required (path to a .etpy source, .bin, .x64.o, .obj, or .coff)");
|
|
continue;
|
|
};
|
|
let stop_on_entry = args["stopOnEntry"].as_bool().unwrap_or(true);
|
|
|
|
// ---- Resolve source + artifact + mode -----------------
|
|
//
|
|
// `program` accepts three shapes; all three converge on
|
|
// the same (source, artifact_path, mode) triple below:
|
|
//
|
|
// 1. `.etpy` - source mode. Sniff the file
|
|
// for `fn go(` vs `fn main(`,
|
|
// pick the build kind, ALWAYS
|
|
// rebuild (so the .obj/.bin
|
|
// on disk matches what the
|
|
// user sees in the editor),
|
|
// then debug the result.
|
|
// This is the recommended F5
|
|
// flow - fully self-describing.
|
|
//
|
|
// 2. `.obj` / `.coff` - BOF artifact path. The
|
|
// sibling `.etpy` is the
|
|
// source; rebuild iff
|
|
// `build: true`.
|
|
//
|
|
// 3. anything else - shellcode artifact path
|
|
// (`.bin` or custom name).
|
|
// Same source-derivation rule.
|
|
//
|
|
// The auto-detect path means the package.json default
|
|
// `program: "${file}"` works without the user having to
|
|
// know which artifact kind their .etpy compiles to.
|
|
let force_build = args["build"].as_bool().unwrap_or(false);
|
|
let explicit_source = args["source"].as_str()
|
|
.filter(|s| !s.is_empty()).map(String::from);
|
|
|
|
let resolved = match resolve_launch_target(
|
|
&program, explicit_source.as_deref(), force_build,
|
|
) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
writer.reply_err(&req, &format!("launch: {e}"));
|
|
continue;
|
|
}
|
|
};
|
|
let ResolvedTarget { source, bin_path, build_kind, should_build } = resolved;
|
|
|
|
// ---- Compile when requested OR when source-mode -------
|
|
//
|
|
// `.etpy` source mode forces a build every launch - the
|
|
// `.obj`/`.bin`/`.dbg` on disk may be stale, mismatched,
|
|
// or absent entirely. Avoiding the rebuild would mean
|
|
// F5 silently running last-week's binary.
|
|
if should_build {
|
|
let Some(ref source_path) = source else {
|
|
writer.reply_err(&req,
|
|
"build requested but no `.etpy` source found. \
|
|
Either pass `program: \"path/to/source.etpy\"` or \
|
|
add `source: \"path/to/source.etpy\"` to launch.json.");
|
|
continue;
|
|
};
|
|
let entc = args["entc"].as_str()
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.or_else(|| find_entc(source_path));
|
|
let Some(entc) = entc else {
|
|
writer.reply_err(&req,
|
|
"build needed but no entc compiler found. Set `entc` in \
|
|
launch.json to the absolute path of entc.exe, or run \
|
|
`cargo build -p entropykit` so target/{release,debug}/entc.exe \
|
|
exists in a parent directory of your source.");
|
|
continue;
|
|
};
|
|
let build_type: Option<&str> = match build_kind {
|
|
BuildMode::Bof => Some("bof"),
|
|
BuildMode::Coff => Some("coff"),
|
|
BuildMode::Standard => None,
|
|
};
|
|
writer.event("output", json!({
|
|
"category": "console",
|
|
"output": format!(
|
|
"[entc-debug] launching {source_path} as {} (artifact: {bin_path})\n",
|
|
match build_kind {
|
|
BuildMode::Bof => "BOF",
|
|
BuildMode::Coff => "COFF",
|
|
BuildMode::Standard => "shellcode",
|
|
}
|
|
),
|
|
}));
|
|
match run_entc_compile(&entc, source_path, build_type) {
|
|
Ok(diag) => {
|
|
if !diag.is_empty() {
|
|
writer.event("output", json!({
|
|
"category": "console",
|
|
"output": diag,
|
|
}));
|
|
}
|
|
}
|
|
Err(BuildErr { exit_code, stderr }) => {
|
|
// Stream compile diagnostics into the
|
|
// Debug Console as stderr so VS Code's
|
|
// problem-matcher styling kicks in.
|
|
writer.event("output", json!({
|
|
"category": "stderr",
|
|
"output": stderr,
|
|
}));
|
|
writer.reply_err(&req, &format!(
|
|
"entc compile failed (exit {exit_code}). See Debug Console for details."));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Final artifact existence check -------------------
|
|
//
|
|
// If we got here without building, the artifact must
|
|
// already exist; otherwise the next `fs::read` deep in
|
|
// the tracer would fail with an opaque message. Catch
|
|
// it now with a precise hint.
|
|
if !Path::new(&bin_path).exists() {
|
|
writer.reply_err(&req, &format!(
|
|
"artifact `{bin_path}` doesn't exist. \
|
|
{} \
|
|
If the source compiles to a different path, set `program` \
|
|
to that path explicitly.",
|
|
if source.is_some() {
|
|
"Set `build: true` in launch.json to compile it from source."
|
|
} else {
|
|
"Pass a `.etpy` source as `program` (recommended) so the \
|
|
debugger auto-builds, or compile it manually with `entc compile`."
|
|
}
|
|
));
|
|
continue;
|
|
}
|
|
|
|
// ---- Source map sidecar -------------------------------
|
|
let dbg_path = match args["dbg"].as_str() {
|
|
Some(s) if !s.is_empty() => s.to_string(),
|
|
_ => default_dbg_for(&bin_path),
|
|
};
|
|
|
|
// ---- Pack BOF args (BOF mode only) --------------------
|
|
let is_bof = matches!(build_kind, BuildMode::Bof | BuildMode::Coff);
|
|
let artifact = if is_bof {
|
|
let arg_tokens: Vec<String> = args["args"].as_array()
|
|
.map(|arr| arr.iter()
|
|
.filter_map(|v| v.as_str().map(String::from))
|
|
.collect())
|
|
.unwrap_or_default();
|
|
match bof_loader::args::parse_packed_args(&arg_tokens) {
|
|
Ok(packed) => ArtifactKind::Bof { args: packed },
|
|
Err(e) => {
|
|
writer.reply_err(&req, &format!("`args`: {e}"));
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
ArtifactKind::Shellcode
|
|
};
|
|
|
|
match SourceMap::load(&dbg_path) {
|
|
Ok(map) => {
|
|
*srv.shared.map.lock().unwrap() = Some(map);
|
|
srv.pending_launch = Some(PendingLaunch {
|
|
bin_path, stop_on_entry, artifact,
|
|
});
|
|
writer.reply_ok(&req, json!({}));
|
|
}
|
|
Err(e) => writer.reply_err(&req, &format!(
|
|
"load source map `{dbg_path}`: {e}. \
|
|
Did the build emit a .dbg sidecar? \
|
|
Set `build: true` in launch.json to force `entc compile --debug`."
|
|
)),
|
|
}
|
|
}
|
|
|
|
"setBreakpoints" => {
|
|
let source_path = req["arguments"]["source"]["path"]
|
|
.as_str().unwrap_or("").to_string();
|
|
let bp_args: Vec<Value> = req["arguments"]["breakpoints"]
|
|
.as_array().cloned().unwrap_or_default();
|
|
|
|
// Translate each requested bp INDIVIDUALLY, preserving
|
|
// the (bp_args, requested_lines, translation) alignment
|
|
// so the metadata indexing below stays correct even
|
|
// when some lines fall between .dbg entries.
|
|
let map_clone = srv.shared.map_clone();
|
|
let translations: Vec<Option<(usize, u32)>> = bp_args.iter().map(|b| {
|
|
let line = b["line"].as_u64().map(|n| n as u32)?;
|
|
let map = map_clone.as_ref()?;
|
|
translate_one_bp(map, &source_path, line)
|
|
}).collect();
|
|
|
|
// Build (offsets, response rows, meta map) in lockstep.
|
|
let mut offsets: Vec<usize> = Vec::new();
|
|
let mut bp_rows: Vec<Value> = Vec::with_capacity(bp_args.len());
|
|
let mut meta = srv.shared.bp_meta.lock().unwrap();
|
|
meta.clear();
|
|
for (i, t) in translations.iter().enumerate() {
|
|
let raw = &bp_args[i];
|
|
let req_line = raw["line"].as_u64().unwrap_or(0) as u32;
|
|
match t {
|
|
Some((off, mapped_line)) => {
|
|
offsets.push(*off);
|
|
// DAP lets the response report a different
|
|
// line from the request - VS Code snaps
|
|
// the gutter marker to where the bp
|
|
// actually landed.
|
|
bp_rows.push(json!({
|
|
"verified": true,
|
|
"line": mapped_line,
|
|
}));
|
|
let condition = raw["condition"].as_str()
|
|
.filter(|s| !s.is_empty()).map(String::from);
|
|
let log_message = raw["logMessage"].as_str()
|
|
.filter(|s| !s.is_empty()).map(String::from);
|
|
if condition.is_some() || log_message.is_some() {
|
|
meta.insert(*off, BpMeta { condition, log_message });
|
|
}
|
|
}
|
|
None => {
|
|
bp_rows.push(json!({
|
|
"verified": false,
|
|
"line": req_line,
|
|
"message": "no .dbg entry at or after this line",
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
drop(meta);
|
|
|
|
srv.user_bps = offsets.clone();
|
|
|
|
// Apply the new set. If the tracer is already running,
|
|
// plant directly into RWX memory so changes take effect
|
|
// BEFORE the next stop. This is what makes "add a
|
|
// breakpoint while the program is running" actually
|
|
// work - previously we routed through Cmd::SetBreakpoints
|
|
// on a channel that the tracer only drains when parked,
|
|
// so a new bp would only fire if some OTHER bp already
|
|
// existed to park on. apply_breakpoints_live is a no-op
|
|
// when the tracer hasn't started yet, in which case the
|
|
// bps will get planted from `initial` in configurationDone.
|
|
tracer::apply_breakpoints_live(&offsets);
|
|
|
|
writer.reply_ok(&req, json!({ "breakpoints": bp_rows }));
|
|
}
|
|
|
|
"setExceptionBreakpoints" => {
|
|
writer.reply_ok(&req, json!({}));
|
|
}
|
|
|
|
"configurationDone" => {
|
|
writer.reply_ok(&req, json!({}));
|
|
if let Some(pl) = srv.pending_launch.take() {
|
|
let mut initial = srv.user_bps.clone();
|
|
let map_for_start = srv.shared.map_clone().expect("map loaded at launch");
|
|
if pl.stop_on_entry {
|
|
if let Some(first) = map_for_start.entries.first() {
|
|
if !initial.contains(&first.offset) {
|
|
initial.push(first.offset);
|
|
}
|
|
}
|
|
}
|
|
let session = TracerSession::new();
|
|
let result = match &pl.artifact {
|
|
ArtifactKind::Shellcode => start_tracer(
|
|
&pl.bin_path, map_for_start, initial,
|
|
session.clone(), tracer_ev_tx.clone(),
|
|
),
|
|
ArtifactKind::Bof { args } => start_tracer_bof(
|
|
&pl.bin_path, map_for_start, initial,
|
|
session.clone(), tracer_ev_tx.clone(),
|
|
args.clone(),
|
|
),
|
|
};
|
|
match result {
|
|
Ok((cmd_tx, join)) => {
|
|
*srv.shared.cmd_tx.lock().unwrap() = Some(cmd_tx);
|
|
*srv.shared.session.lock().unwrap() = Some(session);
|
|
srv.tracer_join = Some(join);
|
|
writer.event("thread", json!({
|
|
"reason": "started",
|
|
"threadId": 1,
|
|
}));
|
|
}
|
|
Err(e) => {
|
|
writer.event("output", json!({
|
|
"category": "stderr",
|
|
"output": format!("entc-debug: {e}\n"),
|
|
}));
|
|
writer.event("terminated", json!({}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
"threads" => {
|
|
writer.reply_ok(&req, json!({
|
|
"threads": [{ "id": 1, "name": "shellcode" }]
|
|
}));
|
|
}
|
|
|
|
"stackTrace" => {
|
|
let map = srv.shared.map_clone();
|
|
let last = *srv.shared.last_stop.lock().unwrap();
|
|
let body = match (map, last) {
|
|
(Some(map), Some((line, col))) => {
|
|
let abs = map.source_path.to_string_lossy().to_string();
|
|
let name = map.source_path.file_name()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
let rip = current_rip(&srv.shared.session_clone()).unwrap_or(0);
|
|
json!({
|
|
"stackFrames": [{
|
|
"id": 1,
|
|
"name": "shellcode",
|
|
"line": line,
|
|
"column": col,
|
|
"instructionPointerReference": format!("0x{rip:x}"),
|
|
"source": { "path": abs, "name": name }
|
|
}],
|
|
"totalFrames": 1
|
|
})
|
|
}
|
|
_ => json!({ "stackFrames": [], "totalFrames": 0 }),
|
|
};
|
|
writer.reply_ok(&req, body);
|
|
}
|
|
|
|
"scopes" => {
|
|
// Order matters - VS Code expands the first scope by
|
|
// default. Locals first so users see their named vars
|
|
// before having to drill into Registers.
|
|
writer.reply_ok(&req, json!({
|
|
"scopes": [
|
|
{
|
|
"name": "Locals",
|
|
"presentationHint": "locals",
|
|
"variablesReference": VARREF_LOCALS,
|
|
"expensive": false,
|
|
},
|
|
{
|
|
"name": "Registers",
|
|
"presentationHint": "registers",
|
|
"variablesReference": VARREF_REGISTERS,
|
|
"expensive": false,
|
|
},
|
|
{
|
|
"name": "Stack",
|
|
"variablesReference": VARREF_STACK,
|
|
"expensive": false,
|
|
}
|
|
]
|
|
}));
|
|
}
|
|
|
|
"variables" => {
|
|
let var_ref = req["arguments"]["variablesReference"].as_i64().unwrap_or(0);
|
|
let session = srv.shared.session_clone();
|
|
let map = srv.shared.map_clone();
|
|
let line = srv.shared.current_line();
|
|
let vars = match var_ref {
|
|
VARREF_REGISTERS => build_registers(&session),
|
|
VARREF_STACK => build_stack(&session),
|
|
VARREF_LOCALS => build_locals(&session, &map, line),
|
|
_ => Vec::new(),
|
|
};
|
|
writer.reply_ok(&req, json!({ "variables": vars }));
|
|
}
|
|
|
|
"disassemble" => {
|
|
let session = srv.shared.session_clone();
|
|
let body = handle_disassemble(&req, &session, &srv.shared.map_clone());
|
|
writer.reply_ok(&req, body);
|
|
}
|
|
|
|
"readMemory" => {
|
|
let session = srv.shared.session_clone();
|
|
let (body, ok) = handle_read_memory(&req, &session);
|
|
if ok {
|
|
writer.reply_ok(&req, body);
|
|
} else {
|
|
writer.reply_err(&req, "readMemory: invalid memoryReference");
|
|
}
|
|
}
|
|
|
|
"evaluate" => {
|
|
let expr = req["arguments"]["expression"].as_str().unwrap_or("").to_string();
|
|
let line = srv.shared.current_line();
|
|
match evaluate(&expr, &srv.shared, line) {
|
|
Ok(v) => writer.reply_ok(&req, json!({
|
|
"result": format_eval_value(&v),
|
|
"variablesReference": 0,
|
|
"memoryReference": match &v {
|
|
EvalValue::Int(n) => format!("0x{n:x}"),
|
|
_ => String::new(),
|
|
},
|
|
})),
|
|
Err(e) => writer.reply_err(&req, &e),
|
|
}
|
|
}
|
|
|
|
"continue" => {
|
|
if let Some(tx) = srv.shared.cmd_tx.lock().unwrap().as_ref() {
|
|
let _ = tx.send(Cmd::Continue);
|
|
}
|
|
writer.reply_ok(&req, json!({ "allThreadsContinued": true }));
|
|
}
|
|
"next" | "stepIn" | "stepOut" => {
|
|
let gran = req["arguments"]["granularity"].as_str().unwrap_or("statement");
|
|
let cmd = if gran == "instruction" { Cmd::StepInstruction } else { Cmd::Next };
|
|
if let Some(tx) = srv.shared.cmd_tx.lock().unwrap().as_ref() {
|
|
let _ = tx.send(cmd);
|
|
}
|
|
writer.reply_ok(&req, json!({}));
|
|
}
|
|
"pause" => {
|
|
let session = srv.shared.session_clone();
|
|
let ok = match (session, &srv.tracer_join) {
|
|
(Some(s), Some(j)) => arm_pause(&s, j),
|
|
_ => false,
|
|
};
|
|
if !ok {
|
|
writer.event("output", json!({
|
|
"category": "console",
|
|
"output": "[entc-debug] pause: nothing to suspend\n",
|
|
}));
|
|
}
|
|
writer.reply_ok(&req, json!({}));
|
|
}
|
|
|
|
"restart" => {
|
|
// VS Code's UI "Restart" button. We acknowledge the
|
|
// request and exit - the editor then invokes the
|
|
// adapter again from scratch, which is cleaner than
|
|
// teardown-and-respawn inside one process given the
|
|
// global VEH state and one-tracer-at-a-time invariant.
|
|
writer.reply_ok(&req, json!({}));
|
|
writer.event("terminated", json!({ "restart": true }));
|
|
std::process::exit(0);
|
|
}
|
|
|
|
"disconnect" | "terminate" => {
|
|
// Acknowledge first so VS Code's UI unblocks, then
|
|
// exit the whole process. We deliberately do NOT
|
|
// try to "let the shellcode finish" - the previous
|
|
// behaviour (restore-and-continue) was why the
|
|
// MessageBox still popped up after the user clicked
|
|
// stop. Killing the adapter process tears down the
|
|
// tracer thread + shellcode + any blocking syscall
|
|
// (MessageBox, Sleep, RPC, ...) atomically.
|
|
writer.reply_ok(&req, json!({}));
|
|
writer.event("terminated", json!({}));
|
|
writer.event("exited", json!({ "exitCode": 0 }));
|
|
std::process::exit(0);
|
|
}
|
|
|
|
_ => { writer.reply_ok(&req, json!({})); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers.
|
|
// ============================================================================
|
|
|
|
fn default_dbg_for(bin: &str) -> String {
|
|
// Multi-segment extensions first so `foo.x64.o` resolves to `foo.dbg`,
|
|
// not `foo.x64.dbg`.
|
|
for ext in [".x64.o", ".x86.o", ".bin", ".obj", ".coff"] {
|
|
if let Some(stem) = bin.strip_suffix(ext) {
|
|
return format!("{stem}.dbg");
|
|
}
|
|
}
|
|
format!("{bin}.dbg")
|
|
}
|
|
|
|
fn start_tracer(
|
|
bin_path: &str,
|
|
map: SourceMap,
|
|
initial_bps: Vec<usize>,
|
|
session: Arc<TracerSession>,
|
|
ev_tx: mpsc::Sender<Event>,
|
|
) -> Result<(mpsc::Sender<Cmd>, thread::JoinHandle<()>), String> {
|
|
let bin = fs::read(bin_path)
|
|
.map_err(|e| format!("read {bin_path}: {e}"))?;
|
|
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
|
let bin_clone = bin.clone();
|
|
let map_clone = map.clone();
|
|
let session_clone = session.clone();
|
|
let join = thread::spawn(move || {
|
|
tracer::run(&bin_clone, map_clone, initial_bps,
|
|
session_clone, ev_tx, cmd_rx);
|
|
});
|
|
let _ = session; // moved into spawn; explicit name keeps the read clear.
|
|
Ok((cmd_tx, join))
|
|
}
|
|
|
|
/// BOF flavour of `start_tracer`. Loads the `.obj` via `bof_loader`
|
|
/// (parsing, mapping, symbol resolution, relocations) and hands the
|
|
/// already-loaded artifact to `tracer::run_bof`. The tracer then
|
|
/// plants `int 3` at the debug-info offsets within `.text` and calls
|
|
/// `go(args, len)` with Win64 calling convention.
|
|
fn start_tracer_bof(
|
|
obj_path: &str,
|
|
map: SourceMap,
|
|
initial_bps: Vec<usize>,
|
|
session: Arc<TracerSession>,
|
|
ev_tx: mpsc::Sender<Event>,
|
|
args: Vec<u8>,
|
|
) -> Result<(mpsc::Sender<Cmd>, thread::JoinHandle<()>), String> {
|
|
let coff_bytes = fs::read(obj_path)
|
|
.map_err(|e| format!("read {obj_path}: {e}"))?;
|
|
// Load NOW (on the DAP server thread) rather than inside the
|
|
// tracer thread, so any COFF parse / relocation error surfaces
|
|
// through the existing `Err` path and the user gets a clean
|
|
// launch failure instead of "debugger died."
|
|
let loaded = bof_loader::load::load(&coff_bytes)?;
|
|
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
|
let map_clone = map.clone();
|
|
let session_clone = session.clone();
|
|
let join = thread::spawn(move || {
|
|
tracer::run_bof(loaded, args, map_clone, initial_bps,
|
|
session_clone, ev_tx, cmd_rx);
|
|
});
|
|
let _ = session;
|
|
Ok((cmd_tx, join))
|
|
}
|
|
|
|
/// Translate one breakpoint request to a planted-offset + the actual
|
|
/// source line we mapped it to (which may differ from the requested
|
|
/// line if the user clicked between .dbg entries).
|
|
///
|
|
/// Strategy:
|
|
/// 1. Confirm the source file matches (canonical-path or
|
|
/// basename fallback).
|
|
/// 2. Exact line match to use that entry.
|
|
/// 3. Otherwise, snap FORWARD to the first entry whose line is
|
|
/// strictly greater. This matches what most debuggers do for
|
|
/// "you clicked the gutter on a line of whitespace / comment".
|
|
/// 4. If nothing past this line is mapped, give up - there's no
|
|
/// executable code we could trap on.
|
|
fn translate_one_bp(map: &SourceMap, source_path: &str, line: u32) -> Option<(usize, u32)> {
|
|
let want = std::fs::canonicalize(source_path).ok();
|
|
let have = std::fs::canonicalize(&map.source_path).ok();
|
|
let same_source = match (&want, &have) {
|
|
(Some(a), Some(b)) => a == b,
|
|
_ => map.source_path.file_name() == Path::new(source_path).file_name(),
|
|
};
|
|
if !same_source { return None; }
|
|
|
|
if let Some(e) = map.entries.iter().find(|e| e.line == line) {
|
|
return Some((e.offset, e.line));
|
|
}
|
|
// Snap forward to the entry with the SMALLEST line strictly
|
|
// greater than the requested one - NOT the first entry in
|
|
// offset order with `line > requested`. `entries` is sorted by
|
|
// offset, so a naïve `find(|e| e.line > requested)` can pick
|
|
// an entry from a different function whose offset happens to
|
|
// come earlier in the file. Comparing on `e.line` gives us the
|
|
// "next executable line BELOW where you clicked" the user
|
|
// expects.
|
|
map.entries.iter()
|
|
.filter(|e| e.line > line)
|
|
.min_by_key(|e| e.line)
|
|
.map(|e| (e.offset, e.line))
|
|
}
|
|
|
|
fn current_rip(session: &Option<Arc<TracerSession>>) -> Option<u64> {
|
|
session.as_ref().and_then(|s| s.regs()).map(|r| r.rip)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- variables
|
|
|
|
/// One row in the Registers scope. The `memoryReference` field is set
|
|
/// to the value formatted as hex so the user can right-click to "View
|
|
/// Binary Data" / open the Memory view at the address held by that
|
|
/// register.
|
|
fn reg(name: &str, value: u64) -> Value {
|
|
json!({
|
|
"name": name,
|
|
"value": format!("0x{value:016x} ({value})"),
|
|
"type": "u64",
|
|
"variablesReference": 0,
|
|
"memoryReference": format!("0x{value:x}"),
|
|
})
|
|
}
|
|
|
|
fn build_registers(session: &Option<Arc<TracerSession>>) -> Vec<Value> {
|
|
let r = match session.as_ref().and_then(|s| s.regs()) {
|
|
Some(r) => r,
|
|
None => return vec![],
|
|
};
|
|
let f = r.rflags;
|
|
// RFLAGS bit positions (Intel SDM Vol 1 §3.4.3).
|
|
let bit = |pos: u32, name: &str| -> Value {
|
|
json!({
|
|
"name": name,
|
|
"value": if (f >> pos) & 1 == 1 { "1" } else { "0" },
|
|
"type": "flag",
|
|
"variablesReference": 0,
|
|
})
|
|
};
|
|
vec![
|
|
reg("RIP", r.rip),
|
|
reg("RFLAGS", r.rflags),
|
|
// Single-bit flag rows. Cheap, and once you've debugged a
|
|
// conditional jump that didn't go where you expected, having
|
|
// ZF/CF/SF inline saves you bit-twiddling RFLAGS in your head.
|
|
bit(0, "CF"), bit(2, "PF"), bit(4, "AF"), bit(6, "ZF"),
|
|
bit(7, "SF"), bit(10, "DF"), bit(11, "OF"),
|
|
reg("RAX", r.rax), reg("RBX", r.rbx), reg("RCX", r.rcx), reg("RDX", r.rdx),
|
|
reg("RSI", r.rsi), reg("RDI", r.rdi),
|
|
reg("RBP", r.rbp), reg("RSP", r.rsp),
|
|
reg("R8", r.r8), reg("R9", r.r9), reg("R10", r.r10), reg("R11", r.r11),
|
|
reg("R12", r.r12), reg("R13", r.r13), reg("R14", r.r14), reg("R15", r.r15),
|
|
]
|
|
}
|
|
|
|
/// The "Locals" scope: named variables in scope at the current line,
|
|
/// resolved through the .dbg's variables section. For each variable
|
|
/// we compute its current address from the registers + textual loc
|
|
/// expression (`rbp-0x8`, `rax`, ...), then read the underlying
|
|
/// qword. Types currently rendered hex/dec like other variables;
|
|
/// future work could format `int`/`bool`/`str` differently.
|
|
fn build_locals(
|
|
session: &Option<Arc<TracerSession>>,
|
|
map: &Option<SourceMap>,
|
|
line: u32,
|
|
) -> Vec<Value> {
|
|
let (regs, map) = match (session.as_ref().and_then(|s| s.regs()), map.as_ref()) {
|
|
(Some(r), Some(m)) => (r, m),
|
|
_ => return Vec::new(),
|
|
};
|
|
let mut out = Vec::new();
|
|
// When the stop has no source mapping (line == 0 - e.g. after a
|
|
// pause that landed mid-statement, or an instruction-step between
|
|
// .dbg entries), don't hide every local. The user is still
|
|
// somewhere inside a function; show ALL declared locals so they
|
|
// can see register-derived values rather than an empty panel.
|
|
let scoped: Vec<_> = if line == 0 {
|
|
map.vars.iter().collect()
|
|
} else {
|
|
map.vars_in_scope(line)
|
|
};
|
|
for v in scoped {
|
|
let addr = match resolve_var_address(&v.loc, ®s) {
|
|
Some(a) => a,
|
|
None => {
|
|
out.push(json!({
|
|
"name": v.name,
|
|
"value": format!("<location \"{}\" not understood>", v.loc),
|
|
"type": v.ty,
|
|
"variablesReference": 0,
|
|
}));
|
|
continue;
|
|
}
|
|
};
|
|
// The location may itself BE a register (no memory read needed):
|
|
// `loc` like `rax` should report rax's value directly.
|
|
if let Some(direct) = direct_register_value(&v.loc, ®s) {
|
|
out.push(json!({
|
|
"name": v.name,
|
|
"value": format_typed_value(direct, &v.ty),
|
|
"type": v.ty,
|
|
"variablesReference": 0,
|
|
"memoryReference": format!("0x{:x}", direct),
|
|
}));
|
|
continue;
|
|
}
|
|
let mut buf = [0u8; 8];
|
|
let n = unsafe { try_read_bytes(addr, &mut buf) };
|
|
let value_str = if n == 8 {
|
|
format_typed_value(u64::from_le_bytes(buf), &v.ty)
|
|
} else {
|
|
"<unreadable>".to_string()
|
|
};
|
|
out.push(json!({
|
|
"name": v.name,
|
|
"value": value_str,
|
|
"type": v.ty,
|
|
"variablesReference": 0,
|
|
"memoryReference": format!("0x{:x}", addr),
|
|
}));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse a location expression into the absolute address of the
|
|
/// variable's storage. Supported shapes:
|
|
/// - `rbp-0x8`, `rbp+0x10`, `rsp-0x4` (general `reg±hex`)
|
|
/// - `rip+0x...` (rip-relative - globals)
|
|
/// Returns None for register-only locations (caller should use
|
|
/// `direct_register_value`) and for malformed expressions.
|
|
fn resolve_var_address(loc: &str, regs: &crate::tracer::RegSnapshot) -> Option<usize> {
|
|
let s = loc.trim().to_ascii_lowercase();
|
|
// Split on the FIRST + or - (after the register name).
|
|
let split = s.find(|c| c == '+' || c == '-')?;
|
|
let (reg, rest) = s.split_at(split);
|
|
let sign: i64 = if rest.starts_with('-') { -1 } else { 1 };
|
|
let raw = &rest[1..];
|
|
let raw = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")).unwrap_or(raw);
|
|
let mag = i64::from_str_radix(raw, 16).ok()?;
|
|
let base = register_by_name(reg.trim(), regs)?;
|
|
Some((base as i64 + sign * mag) as usize)
|
|
}
|
|
|
|
fn direct_register_value(loc: &str, regs: &crate::tracer::RegSnapshot) -> Option<u64> {
|
|
let s = loc.trim().to_ascii_lowercase();
|
|
if s.contains('+') || s.contains('-') { return None; }
|
|
register_by_name(&s, regs)
|
|
}
|
|
|
|
fn register_by_name(name: &str, r: &crate::tracer::RegSnapshot) -> Option<u64> {
|
|
match name {
|
|
"rax" => Some(r.rax), "rbx" => Some(r.rbx), "rcx" => Some(r.rcx), "rdx" => Some(r.rdx),
|
|
"rsi" => Some(r.rsi), "rdi" => Some(r.rdi), "rbp" => Some(r.rbp), "rsp" => Some(r.rsp),
|
|
"r8" => Some(r.r8), "r9" => Some(r.r9), "r10" => Some(r.r10), "r11" => Some(r.r11),
|
|
"r12" => Some(r.r12), "r13" => Some(r.r13), "r14" => Some(r.r14), "r15" => Some(r.r15),
|
|
"rip" => Some(r.rip), "rflags" => Some(r.rflags),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Render a u64 according to a (very loose) source type. `int`/`u32`/
|
|
/// etc. land as `0xhex (decimal)`. `bool` collapses to true/false.
|
|
/// `str` is shown as the pointer (we'd need to read the heap to
|
|
/// dereference, which we'll add when strings become real).
|
|
fn format_typed_value(v: u64, ty: &str) -> String {
|
|
match ty {
|
|
"bool" => if v != 0 { "true".into() } else { "false".into() },
|
|
"i32" | "int" => {
|
|
let signed = v as i64 as i32;
|
|
format!("{signed} (0x{:x})", v & 0xffff_ffff)
|
|
}
|
|
"u32" => format!("{} (0x{:x})", v as u32, v & 0xffff_ffff),
|
|
"i64" => format!("{} (0x{:x})", v as i64, v),
|
|
_ => format!("0x{:016x} ({})", v, v as i64),
|
|
}
|
|
}
|
|
|
|
/// The "Stack" scope: sixteen qwords starting at RSP. Each row reads
|
|
/// the qword inline and exposes its address as a memoryReference so
|
|
/// the user can drill in further.
|
|
fn build_stack(session: &Option<Arc<TracerSession>>) -> Vec<Value> {
|
|
let r = match session.as_ref().and_then(|s| s.regs()) {
|
|
Some(r) => r,
|
|
None => return vec![],
|
|
};
|
|
let mut out = Vec::with_capacity(16);
|
|
for i in 0..16u64 {
|
|
let addr = r.rsp + i * 8;
|
|
let mut buf = [0u8; 8];
|
|
let read = unsafe { try_read_bytes(addr as usize, &mut buf) };
|
|
let value_str = if read == 8 {
|
|
let qword = u64::from_le_bytes(buf);
|
|
format!("0x{qword:016x} ({qword})")
|
|
} else {
|
|
"<unreadable>".to_string()
|
|
};
|
|
out.push(json!({
|
|
"name": format!("[rsp+0x{:02x}]", i * 8),
|
|
"value": value_str,
|
|
"type": "qword",
|
|
"variablesReference": 0,
|
|
"memoryReference": format!("0x{:x}", addr),
|
|
}));
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------- disasm
|
|
|
|
fn handle_disassemble(
|
|
req: &Value,
|
|
session: &Option<Arc<TracerSession>>,
|
|
map: &Option<SourceMap>,
|
|
) -> Value {
|
|
use iced_x86::{Decoder, DecoderOptions, Formatter, Instruction, IntelFormatter};
|
|
|
|
let args = &req["arguments"];
|
|
let mem_ref = args["memoryReference"].as_str().unwrap_or("0");
|
|
let byte_offset = args["offset"].as_i64().unwrap_or(0);
|
|
let instr_offset = args["instructionOffset"].as_i64().unwrap_or(0);
|
|
let count = args["instructionCount"].as_i64().unwrap_or(32).max(1) as usize;
|
|
let resolve_syms = args["resolveSymbols"].as_bool().unwrap_or(false);
|
|
let _ = resolve_syms;
|
|
|
|
let base_addr = parse_hex_address(mem_ref).unwrap_or(0);
|
|
let start_addr = (base_addr as i64).wrapping_add(byte_offset) as u64;
|
|
|
|
// We can only safely disassemble bytes we OWN - the shellcode
|
|
// region. If the requested range straddles the shellcode boundary,
|
|
// we trim it and report invalid instructions for the rest so VS
|
|
// Code's Disassembly View still aligns.
|
|
let (region_base, region_size) = match session.as_ref() {
|
|
Some(s) => (s.base() as u64, s.size() as u64),
|
|
None => (0, 0),
|
|
};
|
|
|
|
// Decode forward from start_addr; if instruction_offset is
|
|
// non-zero we walk backward/forward in instructions using a
|
|
// simple heuristic (re-decode from an aligned anchor).
|
|
let bytes = if start_addr >= region_base && start_addr < region_base + region_size {
|
|
let lo = (start_addr - region_base) as usize;
|
|
let mut hi = lo + 16 * count + 16;
|
|
if hi > region_size as usize { hi = region_size as usize; }
|
|
let mut v = vec![0u8; hi - lo];
|
|
unsafe {
|
|
std::ptr::copy_nonoverlapping(
|
|
(region_base as usize + lo) as *const u8,
|
|
v.as_mut_ptr(),
|
|
hi - lo,
|
|
);
|
|
}
|
|
// Overlay the original byte for any planted breakpoint inside
|
|
// this slice - otherwise the user sees `cc int3` instead of
|
|
// the instruction they set the breakpoint on.
|
|
if let Some(s) = session.as_ref() {
|
|
for (off, orig) in s.planted.lock().unwrap().iter() {
|
|
if *off >= lo && *off < hi {
|
|
v[*off - lo] = *orig;
|
|
}
|
|
}
|
|
}
|
|
v
|
|
} else {
|
|
// Outside our shellcode: try a guarded process-memory read.
|
|
let mut v = vec![0u8; 16 * count];
|
|
let n = unsafe { try_read_bytes(start_addr as usize, &mut v) };
|
|
v.truncate(n);
|
|
v
|
|
};
|
|
|
|
let mut decoder = Decoder::with_ip(64, &bytes, start_addr, DecoderOptions::NONE);
|
|
let mut formatter = IntelFormatter::new();
|
|
formatter.options_mut().set_first_operand_char_index(8);
|
|
let mut instr = Instruction::default();
|
|
let mut out_text = String::new();
|
|
let mut decoded: Vec<Value> = Vec::with_capacity(count);
|
|
|
|
// Skip forward `instr_offset` instructions if positive; for
|
|
// negative we can't safely walk backward without a fuller cache,
|
|
// so we just clamp to zero (Disassembly View still works - it'll
|
|
// show the requested address as the topmost line).
|
|
let mut skip = instr_offset.max(0);
|
|
while skip > 0 && decoder.can_decode() {
|
|
decoder.decode_out(&mut instr);
|
|
skip -= 1;
|
|
}
|
|
|
|
while decoder.can_decode() && decoded.len() < count {
|
|
decoder.decode_out(&mut instr);
|
|
out_text.clear();
|
|
formatter.format(&instr, &mut out_text);
|
|
|
|
// Slice the raw bytes of this instruction.
|
|
let start_off = (instr.ip() - start_addr) as usize;
|
|
let end_off = start_off + instr.len();
|
|
let raw = bytes.get(start_off..end_off)
|
|
.map(|b| b.iter()
|
|
.map(|x| format!("{:02x}", x))
|
|
.collect::<Vec<_>>()
|
|
.join(" "))
|
|
.unwrap_or_default();
|
|
|
|
// Source mapping - attach (line, col, source) per row when we
|
|
// can. VS Code's Disassembly View then interleaves the source
|
|
// text between asm rows. Only the FIRST instruction at each
|
|
// source-line offset gets the location attached; rows that
|
|
// fall between source statements stay unannotated, which is
|
|
// what produces the inline-source layout.
|
|
let mut row = json!({
|
|
"address": format!("0x{:x}", instr.ip()),
|
|
"instructionBytes": raw,
|
|
"instruction": out_text.clone(),
|
|
});
|
|
if let Some(map) = map.as_ref() {
|
|
if instr.ip() >= region_base && instr.ip() < region_base + region_size {
|
|
let off = (instr.ip() - region_base) as usize;
|
|
if let Some(entry) = map.at(off) {
|
|
let abs = map.source_path.to_string_lossy().to_string();
|
|
let name = map.source_path.file_name()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
row["line"] = json!(entry.line);
|
|
row["column"] = json!(entry.col);
|
|
row["location"] = json!({ "path": abs, "name": name });
|
|
}
|
|
}
|
|
}
|
|
decoded.push(row);
|
|
}
|
|
|
|
// Pad with synthetic "??" rows if we got fewer instructions than
|
|
// requested; the Disassembly View expects exactly `count` rows.
|
|
while decoded.len() < count {
|
|
let addr = start_addr.wrapping_add((decoded.len() * 1) as u64);
|
|
decoded.push(json!({
|
|
"address": format!("0x{:x}", addr),
|
|
"instructionBytes": "",
|
|
"instruction": "??",
|
|
}));
|
|
}
|
|
|
|
json!({ "instructions": decoded })
|
|
}
|
|
|
|
fn parse_hex_address(s: &str) -> Option<u64> {
|
|
let t = s.trim();
|
|
let t = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")).unwrap_or(t);
|
|
u64::from_str_radix(t, 16).ok()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- readMemory
|
|
|
|
fn handle_read_memory(req: &Value, session: &Option<Arc<TracerSession>>) -> (Value, bool) {
|
|
let args = &req["arguments"];
|
|
let mem_ref = args["memoryReference"].as_str().unwrap_or("");
|
|
let offset = args["offset"].as_i64().unwrap_or(0);
|
|
let count = args["count"].as_i64().unwrap_or(0).max(0) as usize;
|
|
let base = match parse_hex_address(mem_ref) {
|
|
Some(a) => a,
|
|
None => return (json!({}), false),
|
|
};
|
|
let addr = (base as i64).wrapping_add(offset) as usize;
|
|
|
|
let mut buf = vec![0u8; count];
|
|
let read = unsafe { try_read_bytes(addr, &mut buf) };
|
|
|
|
// Overlay planted-bp originals when the read window intersects
|
|
// the shellcode region - matches the disassemble behaviour so the
|
|
// Hex Editor doesn't display our `0xCC` traps.
|
|
if let Some(s) = session.as_ref() {
|
|
let region_base = s.base();
|
|
let region_end = region_base + s.size();
|
|
if region_base != 0 && addr < region_end && addr + read > region_base {
|
|
for (off, orig) in s.planted.lock().unwrap().iter() {
|
|
let bp_addr = region_base + *off;
|
|
if bp_addr >= addr && bp_addr < addr + read {
|
|
buf[bp_addr - addr] = *orig;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let data_b64 = base64_encode(&buf[..read]);
|
|
let body = json!({
|
|
"address": format!("0x{:x}", addr),
|
|
"data": data_b64,
|
|
"unreadableBytes": (count - read) as i64,
|
|
});
|
|
(body, true)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- evaluate
|
|
//
|
|
// A tiny expression evaluator powering three DAP features:
|
|
//
|
|
// * `evaluate` (Watch / hover / Debug Console)
|
|
// * breakpoint condition ("only stop when this is true")
|
|
// * logpoint placeholders ("{expr} resolves on hit")
|
|
//
|
|
// Grammar (precedence low to high):
|
|
//
|
|
// cmp := add (( "==" | "!=" | "<" | ">" | "<=" | ">=" ) add)?
|
|
// add := unary (("+" | "-") unary)*
|
|
// unary := "-"? primary
|
|
// primary := integer | name | "[" cmp "]" | "(" cmp ")"
|
|
//
|
|
// `name` resolves first as a local-in-scope (via SourceMap.vars), then
|
|
// as a register. `[expr]` reads 8 bytes at `expr` and treats them as a
|
|
// little-endian u64.
|
|
//
|
|
// Returns are tagged: integer results vs. byte slices (future:
|
|
// strings). For now everything collapses to i64 - sufficient for
|
|
// `i == 3`, `rax > 0`, `[rbp-8] != 0`, `i+1`.
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum EvalValue { Int(i64), Bytes(Vec<u8>) }
|
|
|
|
fn evaluate(expr: &str, shared: &SharedDap, line: u32) -> Result<EvalValue, String> {
|
|
let regs = match shared.session_clone().and_then(|s| s.regs()) {
|
|
Some(r) => r,
|
|
None => return Err("no register state - debuggee not stopped".into()),
|
|
};
|
|
let map = shared.map_clone();
|
|
let mut p = Parser { src: expr.as_bytes(), pos: 0, regs: ®s, map: map.as_ref(), line };
|
|
let v = p.parse_cmp()?;
|
|
p.skip_ws();
|
|
if p.pos < p.src.len() {
|
|
return Err(format!("trailing input near `{}`", &expr[p.pos..]));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn evaluate_truthy(expr: &str, shared: &SharedDap, line: u32) -> bool {
|
|
match evaluate(expr, shared, line) {
|
|
Ok(EvalValue::Int(n)) => n != 0,
|
|
Ok(EvalValue::Bytes(b)) => !b.is_empty() && b.iter().any(|&x| x != 0),
|
|
Err(_) => true, // be permissive - bad condition shouldn't silently skip
|
|
}
|
|
}
|
|
|
|
fn format_eval_value(v: &EvalValue) -> String {
|
|
match v {
|
|
EvalValue::Int(n) => format!("0x{:x} ({})", *n as u64, n),
|
|
EvalValue::Bytes(b) => format!("{} bytes", b.len()),
|
|
}
|
|
}
|
|
|
|
/// Render a DAP logpoint template. Curly braces are placeholders:
|
|
/// `i={i} rax={rax}` substitutes each `{expr}` with the evaluated
|
|
/// value. Doubled braces `{{` / `}}` are literal.
|
|
fn render_log_message(template: &str, shared: &SharedDap, line: u32) -> String {
|
|
let mut out = String::with_capacity(template.len());
|
|
let bytes = template.as_bytes();
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
let c = bytes[i] as char;
|
|
if c == '{' && bytes.get(i + 1).copied() == Some(b'{') {
|
|
out.push('{'); i += 2; continue;
|
|
}
|
|
if c == '}' && bytes.get(i + 1).copied() == Some(b'}') {
|
|
out.push('}'); i += 2; continue;
|
|
}
|
|
if c == '{' {
|
|
// Find matching `}`.
|
|
let start = i + 1;
|
|
let end = match bytes[start..].iter().position(|&b| b == b'}') {
|
|
Some(p) => start + p,
|
|
None => { out.push(c); i += 1; continue; }
|
|
};
|
|
let expr = &template[start..end];
|
|
let rendered = match evaluate(expr, shared, line) {
|
|
Ok(v) => format_eval_value(&v),
|
|
Err(e) => format!("<err: {e}>"),
|
|
};
|
|
out.push_str(&rendered);
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
out.push(c);
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
struct Parser<'a> {
|
|
src: &'a [u8],
|
|
pos: usize,
|
|
regs: &'a crate::tracer::RegSnapshot,
|
|
map: Option<&'a SourceMap>,
|
|
line: u32,
|
|
}
|
|
|
|
impl<'a> Parser<'a> {
|
|
fn skip_ws(&mut self) {
|
|
while self.pos < self.src.len() && (self.src[self.pos] as char).is_whitespace() {
|
|
self.pos += 1;
|
|
}
|
|
}
|
|
fn peek(&mut self) -> Option<u8> {
|
|
self.skip_ws();
|
|
self.src.get(self.pos).copied()
|
|
}
|
|
fn eat(&mut self, s: &[u8]) -> bool {
|
|
self.skip_ws();
|
|
if self.src[self.pos..].starts_with(s) { self.pos += s.len(); true } else { false }
|
|
}
|
|
|
|
fn parse_cmp(&mut self) -> Result<EvalValue, String> {
|
|
let l = self.parse_add()?;
|
|
let op = if self.eat(b"==") { Some("==") }
|
|
else if self.eat(b"!=") { Some("!=") }
|
|
else if self.eat(b"<=") { Some("<=") }
|
|
else if self.eat(b">=") { Some(">=") }
|
|
else if self.eat(b"<") { Some("<") }
|
|
else if self.eat(b">") { Some(">") }
|
|
else { None };
|
|
if let Some(op) = op {
|
|
let r = self.parse_add()?;
|
|
let li = as_int(&l)?; let ri = as_int(&r)?;
|
|
let b = match op {
|
|
"==" => li == ri, "!=" => li != ri,
|
|
"<" => li < ri, ">" => li > ri,
|
|
"<=" => li <= ri, ">=" => li >= ri,
|
|
_ => unreachable!(),
|
|
};
|
|
return Ok(EvalValue::Int(if b { 1 } else { 0 }));
|
|
}
|
|
Ok(l)
|
|
}
|
|
fn parse_add(&mut self) -> Result<EvalValue, String> {
|
|
let mut l = self.parse_unary()?;
|
|
loop {
|
|
if self.eat(b"+") {
|
|
let r = self.parse_unary()?;
|
|
l = EvalValue::Int(as_int(&l)?.wrapping_add(as_int(&r)?));
|
|
} else if self.eat(b"-") {
|
|
let r = self.parse_unary()?;
|
|
l = EvalValue::Int(as_int(&l)?.wrapping_sub(as_int(&r)?));
|
|
} else { break; }
|
|
}
|
|
Ok(l)
|
|
}
|
|
fn parse_unary(&mut self) -> Result<EvalValue, String> {
|
|
if self.eat(b"-") {
|
|
let v = self.parse_primary()?;
|
|
return Ok(EvalValue::Int(-as_int(&v)?));
|
|
}
|
|
self.parse_primary()
|
|
}
|
|
fn parse_primary(&mut self) -> Result<EvalValue, String> {
|
|
self.skip_ws();
|
|
let b = match self.src.get(self.pos) {
|
|
Some(&b) => b,
|
|
None => return Err("unexpected end of expression".into()),
|
|
};
|
|
if b == b'(' {
|
|
self.pos += 1;
|
|
let v = self.parse_cmp()?;
|
|
if !self.eat(b")") { return Err("expected `)`".into()); }
|
|
return Ok(v);
|
|
}
|
|
if b == b'[' {
|
|
self.pos += 1;
|
|
let v = self.parse_cmp()?;
|
|
if !self.eat(b"]") { return Err("expected `]`".into()); }
|
|
let addr = as_int(&v)? as usize;
|
|
let mut buf = [0u8; 8];
|
|
let n = unsafe { try_read_bytes(addr, &mut buf) };
|
|
if n != 8 { return Err(format!("read 8 bytes at 0x{addr:x}: only got {n}")); }
|
|
return Ok(EvalValue::Int(i64::from_le_bytes(buf)));
|
|
}
|
|
if b.is_ascii_digit() {
|
|
return self.parse_number();
|
|
}
|
|
if b.is_ascii_alphabetic() || b == b'_' {
|
|
return self.parse_name();
|
|
}
|
|
Err(format!("unexpected character `{}`", b as char))
|
|
}
|
|
fn parse_number(&mut self) -> Result<EvalValue, String> {
|
|
let start = self.pos;
|
|
// Hex?
|
|
if self.src[self.pos] == b'0' && self.src.get(self.pos + 1).map_or(false, |&c| c == b'x' || c == b'X') {
|
|
self.pos += 2;
|
|
let s = self.pos;
|
|
while self.pos < self.src.len() && (self.src[self.pos] as char).is_ascii_hexdigit() {
|
|
self.pos += 1;
|
|
}
|
|
let txt = std::str::from_utf8(&self.src[s..self.pos]).unwrap();
|
|
return i64::from_str_radix(txt, 16)
|
|
.map(EvalValue::Int)
|
|
.map_err(|e| format!("bad hex literal: {e}"));
|
|
}
|
|
while self.pos < self.src.len() && (self.src[self.pos] as char).is_ascii_digit() {
|
|
self.pos += 1;
|
|
}
|
|
let txt = std::str::from_utf8(&self.src[start..self.pos]).unwrap();
|
|
txt.parse::<i64>().map(EvalValue::Int).map_err(|e| format!("bad number: {e}"))
|
|
}
|
|
fn parse_name(&mut self) -> Result<EvalValue, String> {
|
|
let start = self.pos;
|
|
while self.pos < self.src.len() {
|
|
let c = self.src[self.pos] as char;
|
|
if !(c.is_ascii_alphanumeric() || c == '_') { break; }
|
|
self.pos += 1;
|
|
}
|
|
let name = std::str::from_utf8(&self.src[start..self.pos]).unwrap();
|
|
// Local first, then register.
|
|
if let Some(map) = self.map {
|
|
for v in map.vars_in_scope(self.line) {
|
|
if v.name == name {
|
|
let addr = resolve_var_address(&v.loc, self.regs);
|
|
if let Some(direct) = direct_register_value(&v.loc, self.regs) {
|
|
return Ok(EvalValue::Int(direct as i64));
|
|
}
|
|
if let Some(addr) = addr {
|
|
let mut buf = [0u8; 8];
|
|
let n = unsafe { try_read_bytes(addr, &mut buf) };
|
|
if n != 8 {
|
|
return Err(format!("read local `{name}` at 0x{addr:x}: got {n} bytes"));
|
|
}
|
|
return Ok(EvalValue::Int(i64::from_le_bytes(buf)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(v) = register_by_name(&name.to_ascii_lowercase(), self.regs) {
|
|
return Ok(EvalValue::Int(v as i64));
|
|
}
|
|
Err(format!("unknown name `{name}`"))
|
|
}
|
|
}
|
|
|
|
fn as_int(v: &EvalValue) -> Result<i64, String> {
|
|
match v {
|
|
EvalValue::Int(n) => Ok(*n),
|
|
_ => Err("expected integer".into()),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- auto-build
|
|
//
|
|
// `build: true` in launch.json runs `entc compile <source> --debug`
|
|
// before loading the .dbg. Resolves three issues:
|
|
//
|
|
// * Edit-restart loop: no more stop / compile / F5 dance.
|
|
// * Stale .dbg: the source-map vars section can drift out of sync
|
|
// with the source, which manifests as "Locals shows nothing".
|
|
// Always-rebuild ensures the .dbg matches what's actually running.
|
|
// * Wrong-compile-flags: if a previous run used `--release` without
|
|
// --debug, the .dbg is stale or missing. We force --debug here.
|
|
|
|
struct BuildErr {
|
|
exit_code: i32,
|
|
stderr: String,
|
|
}
|
|
|
|
fn run_entc_compile(entc: &str, source: &str, build_type: Option<&str>) -> Result<String, BuildErr> {
|
|
let mut cmd = std::process::Command::new(entc);
|
|
cmd.arg("compile").arg(source).arg("--debug");
|
|
if let Some(t) = build_type {
|
|
cmd.arg(format!("--type={t}"));
|
|
}
|
|
let out = cmd
|
|
.output()
|
|
.map_err(|e| BuildErr {
|
|
exit_code: -1,
|
|
stderr: format!("failed to spawn `{entc}`: {e}\n"),
|
|
})?;
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
if !out.status.success() {
|
|
return Err(BuildErr {
|
|
exit_code: out.status.code().unwrap_or(-1),
|
|
stderr: if stderr.is_empty() { stdout } else { stderr + &stdout },
|
|
});
|
|
}
|
|
// entc prints "wrote foo.bin (N bytes)" + .dbg + .entc.js to
|
|
// stderr. Surface it so the user knows what got built.
|
|
Ok(stderr + &stdout)
|
|
}
|
|
|
|
/// Build kind decided at launch time. Mirrors the compiler's
|
|
/// `BuildKind` but stays a thin string-ish enum here so the adapter
|
|
/// doesn't depend on the compiler crate's types.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum BuildMode { Standard, Bof, Coff }
|
|
|
|
/// Resolved (source, artifact, mode) triple for one launch request.
|
|
struct ResolvedTarget {
|
|
/// `.etpy` source path when known. `None` only when the user
|
|
/// pointed `program` at a pre-built artifact and no sibling
|
|
/// `.etpy` exists - at that point a rebuild is impossible and
|
|
/// `should_build` is forced to false.
|
|
source: Option<String>,
|
|
/// Final path on disk we'll load as the runnable artifact -
|
|
/// `.bin` for shellcode, `.obj`/`.coff` for BOF.
|
|
bin_path: String,
|
|
/// Inferred build kind. Drives the `--type=` flag during rebuild
|
|
/// AND the `ArtifactKind` selection during execution.
|
|
build_kind: BuildMode,
|
|
/// True when the adapter should compile before loading. Always
|
|
/// true in source mode (`.etpy` as `program`) so the artifact
|
|
/// matches the source the user can see in the editor; otherwise
|
|
/// follows the `build` field from launch.json.
|
|
should_build: bool,
|
|
}
|
|
|
|
/// Single resolution pass. Handles all three `program` shapes
|
|
/// (.etpy source / .obj artifact / .bin artifact) and works out the
|
|
/// build mode by sniffing the source when possible.
|
|
fn resolve_launch_target(
|
|
program: &str,
|
|
explicit_source: Option<&str>,
|
|
force_build: bool,
|
|
) -> Result<ResolvedTarget, String> {
|
|
// Shape 1: `program` is the source itself. Sniff it for `fn go`
|
|
// (BOF) vs `fn main` (shellcode); rebuild EVERY launch so the
|
|
// resulting `.obj`/`.bin` is fresh. This is the F5 sweet spot:
|
|
// VS Code's `${file}` always resolves to the active editor's
|
|
// path, so as long as the user has the .etpy focused, this path
|
|
// is the one chosen.
|
|
if program.ends_with(".etpy") {
|
|
if !Path::new(program).exists() {
|
|
return Err(format!("source `{program}` doesn't exist"));
|
|
}
|
|
let mode = detect_build_mode_from_source(program)
|
|
.map_err(|e| format!("read `{program}`: {e}"))?;
|
|
let bin_path = artifact_path_for(program, mode);
|
|
return Ok(ResolvedTarget {
|
|
source: Some(program.to_string()),
|
|
bin_path,
|
|
build_kind: mode,
|
|
should_build: true, // source mode always rebuilds
|
|
});
|
|
}
|
|
|
|
// Shape 2/3: `program` is a pre-built artifact. Honour the path
|
|
// the user gave, but try to surface the matching `.etpy` source
|
|
// (via `explicit_source` first, then the obvious sibling) so we
|
|
// can support `build: true` from launch.json AND so the source
|
|
// map's "this is the file your stack frame is placed in" actually
|
|
// resolves to the right .etpy.
|
|
//
|
|
// If the requested artifact is missing but a sibling .etpy is
|
|
// there, switch the resolution path to source mode - the user
|
|
// is on F5 with no pre-built artifact and clearly wants us to
|
|
// build it. Avoids the historic "F5 reports 'file not found' on
|
|
// a fresh checkout" trap.
|
|
let mode_from_ext = match () {
|
|
_ if program.ends_with(".x64.o") => BuildMode::Bof,
|
|
_ if program.ends_with(".x86.o") => BuildMode::Bof,
|
|
// `.obj` retained for `--type=coff` (generic Windows COFF)
|
|
// and for backward compat with BOFs built before the .x64.o
|
|
// convention change. Treated as BOF here since that was the
|
|
// historical default.
|
|
_ if program.ends_with(".obj") => BuildMode::Bof,
|
|
_ if program.ends_with(".coff") => BuildMode::Coff,
|
|
_ => BuildMode::Standard,
|
|
};
|
|
let source = explicit_source.map(String::from)
|
|
.or_else(|| etpy_for(program).filter(|p| Path::new(p).exists()));
|
|
|
|
if !Path::new(program).exists() {
|
|
// Try to upgrade to source mode if we found a sibling .etpy.
|
|
if let Some(ref src) = source {
|
|
let mode = detect_build_mode_from_source(src)
|
|
.map_err(|e| format!("read `{src}`: {e}"))?;
|
|
let bin_path = artifact_path_for(src, mode);
|
|
return Ok(ResolvedTarget {
|
|
source: Some(src.clone()),
|
|
bin_path,
|
|
build_kind: mode,
|
|
should_build: true,
|
|
});
|
|
}
|
|
// Last-ditch: try the standard .bin to .x64.o / .obj / .coff
|
|
// auto-swap that earlier versions of the adapter did.
|
|
// Lets `program: "...bin"` keep working when the sibling
|
|
// .x64.o (current convention) or .obj (legacy) exists.
|
|
if let Some(stem) = program.strip_suffix(".bin") {
|
|
for (ext, alt_mode) in [(".x64.o", BuildMode::Bof), (".obj", BuildMode::Bof), (".coff", BuildMode::Coff)] {
|
|
let cand = format!("{stem}{ext}");
|
|
if Path::new(&cand).exists() {
|
|
return Ok(ResolvedTarget {
|
|
source,
|
|
bin_path: cand,
|
|
build_kind: alt_mode,
|
|
should_build: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return Err(format!(
|
|
"program `{program}` doesn't exist and no sibling `.etpy` to build from"
|
|
));
|
|
}
|
|
|
|
Ok(ResolvedTarget {
|
|
source,
|
|
bin_path: program.to_string(),
|
|
build_kind: mode_from_ext,
|
|
should_build: force_build,
|
|
})
|
|
}
|
|
|
|
/// Read the first ~16 KB of a `.etpy` source and decide which build
|
|
/// kind it targets. Looks for the unambiguous entry-point spellings:
|
|
/// `fn go(` => BOF, `fn main(` => standard. Falls back to standard so
|
|
/// brand-new empty files don't error out.
|
|
///
|
|
/// Cheap one-shot scan - no parser, no allocations beyond reading
|
|
/// the file. `//` line comments aren't stripped but the heuristic is
|
|
/// tolerant: a comment line containing literal `fn go(` would
|
|
/// misclassify, but that's a corner case the user can always fix by
|
|
/// adding `--type=` explicitly.
|
|
fn detect_build_mode_from_source(path: &str) -> std::io::Result<BuildMode> {
|
|
use std::io::Read;
|
|
let mut f = fs::File::open(path)?;
|
|
let mut buf = Vec::with_capacity(16 * 1024);
|
|
// Cap at 64 KB - every real .etpy is much smaller, and capping
|
|
// protects against a huge generated source surprising us.
|
|
let _ = f.take(64 * 1024).read_to_end(&mut buf);
|
|
let src = String::from_utf8_lossy(&buf);
|
|
if src.contains("fn go(") || src.contains("fn go (") {
|
|
return Ok(BuildMode::Bof);
|
|
}
|
|
Ok(BuildMode::Standard)
|
|
}
|
|
|
|
/// Compute the output artifact path `entc compile` will write for
|
|
/// a given source + build kind. Mirrors `main.rs::default_output`
|
|
/// EXACTLY so the adapter's expectation lines up with what the
|
|
/// compiler actually produces - artifacts now land in a `bin/`
|
|
/// subdirectory next to the source.
|
|
fn artifact_path_for(source: &str, mode: BuildMode) -> String {
|
|
let p = Path::new(source);
|
|
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("a");
|
|
let bin_dir = match p.parent() {
|
|
Some(d) if !d.as_os_str().is_empty() => d.join("bin"),
|
|
_ => PathBuf::from("bin"),
|
|
};
|
|
let ext = match mode {
|
|
BuildMode::Standard => "bin",
|
|
// Cobalt Strike's BOF convention is `name.x64.o`.
|
|
// Generic COFF stays `.obj`.
|
|
BuildMode::Bof => "x64.o",
|
|
BuildMode::Coff => "obj",
|
|
};
|
|
bin_dir.join(format!("{stem}.{ext}")).to_string_lossy().to_string()
|
|
}
|
|
|
|
/// Derive the .etpy source path next to a built artifact. Returns
|
|
/// `None` when the path doesn't end in a recognised artifact
|
|
/// extension.
|
|
///
|
|
/// The compiler now defaults outputs to `<source-dir>/bin/`. So
|
|
/// an artifact like `example/bin/foo.x64.o` corresponds to source
|
|
/// `example/foo.etpy`. We strip the artifact's filename suffix
|
|
/// AND, when the parent directory is named `bin`, hop one level
|
|
/// up. Artifacts the operator placed somewhere else still resolve
|
|
/// against their own directory - the fallback covers
|
|
/// `-o some/path/foo.bin` builds.
|
|
fn etpy_for(bin: &str) -> Option<String> {
|
|
let p = Path::new(bin);
|
|
let stem = {
|
|
let name = p.file_name()?.to_string_lossy().to_string();
|
|
let mut s = name.as_str();
|
|
let mut found = false;
|
|
for ext in [".x64.o", ".x86.o", ".bin", ".obj", ".coff"] {
|
|
if let Some(rest) = s.strip_suffix(ext) {
|
|
s = rest;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if !found { return None; }
|
|
s.to_string()
|
|
};
|
|
let candidate_with_hop = p.parent().and_then(|dir| {
|
|
// If artifact is placed in `.../bin/foo.x64.o`, the source is
|
|
// `.../foo.etpy`. The hop only fires when the immediate
|
|
// parent dir is literally named `bin` - keeps the resolver
|
|
// predictable.
|
|
if dir.file_name().map(|n| n == "bin").unwrap_or(false) {
|
|
dir.parent().map(|gp| gp.join(format!("{stem}.etpy")))
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(hop) = candidate_with_hop {
|
|
if hop.exists() {
|
|
return Some(hop.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
// Fallback: same directory as the artifact (handles -o paths
|
|
// that bypassed the bin/ convention).
|
|
let same_dir = p.with_file_name(format!("{stem}.etpy"));
|
|
Some(same_dir.to_string_lossy().to_string())
|
|
}
|
|
|
|
/// Walk up the directory tree from `source_path` looking for a
|
|
/// `target/release/entc.exe` (preferred) or `target/debug/entc.exe`.
|
|
/// Stops at the filesystem root. The walk lets users put the
|
|
/// debugger config in any workspace whose Rust target dir is at or
|
|
/// above the source file - the common case for monorepos and the
|
|
/// EntropyKit workspace layout.
|
|
fn find_entc(source_path: &str) -> Option<String> {
|
|
let mut cursor = Path::new(source_path).parent()?.to_path_buf();
|
|
let exe = if cfg!(windows) { "entc.exe" } else { "entc" };
|
|
loop {
|
|
for profile in ["release", "debug"] {
|
|
let cand = cursor.join("target").join(profile).join(exe);
|
|
if cand.exists() {
|
|
return Some(cand.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
match cursor.parent() {
|
|
Some(p) if p != cursor => cursor = p.to_path_buf(),
|
|
_ => return None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- pause
|
|
|
|
/// Arm a pause on the tracer thread. We suspend it, set TF on its
|
|
/// context, mark `pause_pending` on the session so the VEH reports
|
|
/// reason=Pause when it traps, then resume. The CPU executes exactly
|
|
/// one more instruction before the SINGLE_STEP trap fires.
|
|
///
|
|
/// Returns false if any Win32 call fails (in which case the tracer
|
|
/// is left running, and we surface a console hint rather than
|
|
/// silently swallowing the user's click).
|
|
#[cfg(windows)]
|
|
fn arm_pause(session: &Arc<TracerSession>, join: &thread::JoinHandle<()>) -> bool {
|
|
use std::os::windows::io::AsRawHandle;
|
|
use std::sync::atomic::Ordering;
|
|
use windows_sys::Win32::System::Diagnostics::Debug::{
|
|
GetThreadContext, SetThreadContext, CONTEXT, CONTEXT_CONTROL_AMD64,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{ResumeThread, SuspendThread};
|
|
|
|
let h = join.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
|
|
if h.is_null() { return false; }
|
|
|
|
// SuspendThread returns the previous suspend count; -1 (DWORD
|
|
// max) means failure.
|
|
let prev = unsafe { SuspendThread(h) };
|
|
if prev == u32::MAX { return false; }
|
|
|
|
let mut ctx: CONTEXT = unsafe { std::mem::zeroed() };
|
|
ctx.ContextFlags = CONTEXT_CONTROL_AMD64;
|
|
let got = unsafe { GetThreadContext(h, &mut ctx) };
|
|
if got == 0 {
|
|
unsafe { ResumeThread(h); }
|
|
return false;
|
|
}
|
|
ctx.EFlags |= 0x100;
|
|
let set = unsafe { SetThreadContext(h, &ctx) };
|
|
if set == 0 {
|
|
unsafe { ResumeThread(h); }
|
|
return false;
|
|
}
|
|
|
|
session.pause_pending.store(true, Ordering::SeqCst);
|
|
unsafe { ResumeThread(h); }
|
|
true
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn arm_pause(_session: &Arc<TracerSession>, _join: &thread::JoinHandle<()>) -> bool {
|
|
false
|
|
}
|
|
|
|
// ---------------------------------------------------------------- safe memory probe
|
|
|
|
/// Read up to `out.len()` bytes from `addr` in the current process,
|
|
/// guarded by VirtualQuery so we don't fault on an unmapped page.
|
|
/// Returns the number of bytes successfully read (0 on bad address).
|
|
///
|
|
/// Used by readMemory, the Stack variables scope, and disasm-outside-
|
|
/// shellcode paths. Same-process reads are fine because the tracer
|
|
/// shares its address space with the DAP server (they're threads in
|
|
/// the same process).
|
|
unsafe fn try_read_bytes(addr: usize, out: &mut [u8]) -> usize {
|
|
use windows_sys::Win32::System::Memory::{
|
|
VirtualQuery, MEMORY_BASIC_INFORMATION,
|
|
MEM_COMMIT, PAGE_NOACCESS, PAGE_GUARD,
|
|
};
|
|
|
|
if addr == 0 || out.is_empty() { return 0; }
|
|
|
|
let mut copied = 0usize;
|
|
let mut cursor = addr;
|
|
while copied < out.len() {
|
|
let mut info = std::mem::zeroed::<MEMORY_BASIC_INFORMATION>();
|
|
let qsize = std::mem::size_of::<MEMORY_BASIC_INFORMATION>();
|
|
let got = VirtualQuery(cursor as *const _, &mut info, qsize);
|
|
if got == 0 { break; }
|
|
if info.State != MEM_COMMIT { break; }
|
|
if info.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { break; }
|
|
|
|
let region_end = info.BaseAddress as usize + info.RegionSize;
|
|
let chunk_end = region_end.min(addr + out.len());
|
|
let chunk_len = chunk_end - cursor;
|
|
if chunk_len == 0 { break; }
|
|
|
|
std::ptr::copy_nonoverlapping(
|
|
cursor as *const u8,
|
|
out.as_mut_ptr().add(copied),
|
|
chunk_len,
|
|
);
|
|
copied += chunk_len;
|
|
cursor = chunk_end;
|
|
}
|
|
copied
|
|
}
|
|
|
|
// ---------------------------------------------------------------- base64
|
|
|
|
/// Tiny base64 encoder for readMemory responses. Inlined to avoid a
|
|
/// dep on `base64` - the DAP wire format uses standard alphabet with
|
|
/// `=` padding, which is two dozen lines.
|
|
fn base64_encode(bytes: &[u8]) -> String {
|
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
|
|
let mut i = 0;
|
|
while i + 3 <= bytes.len() {
|
|
let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32);
|
|
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n ) & 0x3f) as usize] as char);
|
|
i += 3;
|
|
}
|
|
let rem = bytes.len() - i;
|
|
if rem == 1 {
|
|
let n = (bytes[i] as u32) << 16;
|
|
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
|
|
out.push('=');
|
|
out.push('=');
|
|
} else if rem == 2 {
|
|
let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
|
|
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
|
|
out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
|
|
out.push('=');
|
|
}
|
|
out
|
|
}
|