Skip to content

feat(shell,ssh): terminal support, session lifetime, and an audit log… - #261

Open
VainXploits wants to merge 1 commit into
ait-testbed:developmentfrom
VainXploits:feat/terminal-and-session-handling
Open

VainXploits wants to merge 1 commit into
ait-testbed:developmentfrom
VainXploits:feat/terminal-and-session-handling

Conversation

@VainXploits

@VainXploits VainXploits commented Sep 8, 2026

Copy link
Copy Markdown

Task

Addresses:

  • #AAA - shell and ssh cannot drive programs that require a terminal
  • #BBB - sessions and background processes outlive the run
  • #CCC - attackmate.json cannot say what a step did
  • #DDD - a dropped session ends the playbook with an uncaught BrokenPipeError
  • #EEE - variable templating silently rewrites $$
  • #FFF - ssh interactive read loop burns a CPU core and mangles multi-byte output

Description

Three groups of change: making it possible to drive programs that need a terminal,
stopping sessions and cleanup leaking between runs, and making attackmate.json say
what a step actually did.

1. Programs that require a terminal

A shell command is connected to pipes, so what it starts has no terminal and no
controlling terminal at all:

  • vim and nano report "Output is not to a terminal" and never draw a screen
  • sudo, su and ssh read passwords from /dev/tty rather than stdin, so a
    password sent as the next command never reaches them

The documentation already promised otherwise — session/index.rst walked through
editing a file with vim in interactive mode, and shell.rst cited vim as the
motivating example. Neither could work: interactive mode only limits how long
AttackMate waits, but the command still talks to a pipe. Both pages are corrected.

pty: True runs the shell behind a pseudo-terminal. Allocating the pty is not
sufficient on its own — the child must also adopt it as its controlling terminal
(TIOCSCTTY), which is what makes /dev/tty exist.

commands:
  - type: shell
    cmd: "vim /tmp/notes\n"
    pty: True
    creates_session: editor

  - type: shell
    cmd: "oHello World<ESC>:wq!\n"     # <ESC> is a real escape key
    pty: True
    session: editor

Sessions are raw by default (raw: false to opt out). A fresh pty comes up in the
line-editing mode meant for a human at a keyboard. That is invisible for a local
full-screen program, which sets raw mode itself, and fatal when the session is a
transport — a program on a remote host can never change the line discipline of a
terminal on the attacker's host:

  • icanon holds a lone <C-o> or <C-x> in the local line buffer, so it never travels
  • isig turns <C-c> into a signal against the local shell instead of sending it onward
  • echo returns every command, so a session's output contains the commands as well as
    their results — and that is what error_if and save see

With icrnl off a full-screen program needs a real carriage return: send <CR>, not
\n, to answer a prompt inside one. Shell command lines are unaffected.

Output handling. By default escape sequences are removed and cursor movements are
applied, so p\x08printf (shell completion) resolves to printf rather than
pprintf — text that never appeared on screen and which defeats error_if.
screen: True renders the emulated screen instead, the only way to read a program that
paints itself by moving the cursor.

Keystrokes are written as names: <ESC>, <CR>, <TAB>, <UP>, <F1>, <C-x>.
On by default for pty, off elsewhere, so existing playbooks send their text verbatim.
<LT> gives a literal <.

Interactive ssh commands already run behind a terminal on the remote host, so they
need no pty — but their output was the raw byte stream. They now share the same
stripping, screen rendering and key expansion. Key expansion defaults to off there,
because such playbooks already exist.

2. Sessions and cleanup no longer leak between runs

Pipe sessions outlived the run. clean_sessions closed the pty store and skipped
the pipe store, on the grounds that those Popen objects are closed by the executor —
true of an ordinary command, false of a session, which _exec_cmd deliberately leaves
open. A session that started a listener kept it alive after AttackMate exited, still
holding its port. The next run could not bind, its reverse shell never connected, and
because a shell step's returncode was hardcoded 0, every step after that was still
recorded as a success. One run silently corrupted the next.

Killing the shell is not enough — nc -l & outlives its parent — so the whole process
group is signalled. That required giving sessions their own group first: without
start_new_session a session shares AttackMate's group, and signalling it would take
AttackMate down too. Ordinary commands are unchanged and still share the parent group.

Cleanup was skipped whenever a run ended badly. It sat in the try after
_run_commands, so it ran only on the happy path. This is worse than it sounds:
exit_on_error, error_if, error_if_not and the loop conditions all end a run with
exit(1), and the resulting SystemExit is neither an Exception nor a
KeyboardInterrupt — it passed straight through the handler here and the one in
__main__. The most common way for a run to fail was also the way that guaranteed a
live session was left behind on the target. Cleanup now runs in a finally, with its
own error handling so a failing teardown cannot mask what ended the run.

Dead sessions fail cleanly on both paths. The pipe path wrote into a closed shell
and died of an uncaught BrokenPipeError, ending the playbook — which is exactly how a
dropped reverse shell presents.

3. The audit log says what a step did

Entries were start time, type, cmd and parameters. No end time, no duration, no exit
status — so everything about what a step did had to be reconstructed elsewhere, and
the presence of a line meant ATTEMPTED, never SUCCEEDED.

Records now carry end-datetime, duration-seconds and exit-status. The real status
was available in two places and discarded: proc.returncode after communicate(), and
recv_exit_status() on the ssh path, which was never called — so ssh's only failure
signal was "wrote something to stderr", which calls a noisy successful command a failure
and a silent failure a success.

Inside a live session no true status exists, because the shell is still running. That is
recorded as null rather than faked as 0.

Making a real status authoritative would break playbooks that have always passed, so
returncode — what exit_on_error acts on — keeps its historical value unless a
command sets use_exit_code: true.

Three further gaps in the same area:

  • A step killed by an uncaught exception was never written to the log, because the
    record was produced after _exec_cmd returned. The artifact showed a playbook that
    simply stopped, with no trace of the step that ended it.
  • The start timestamp was bound inside that same try, so a failure in log_command
    reachable through a non-numeric ssh port — became an UnboundLocalError in the error
    handler.
  • A step skipped by only_if returned before any logging, so it was absent entirely,
    which reads exactly like a step that was never in the playbook. Skipped steps are now
    recorded as such.

4. New options and commands

substitute_cmd_vars: false runs a command exactly as written. string.Template
collapses $$ into a single $, and in a shell $$ is the process id — so
kill -9 $$, /tmp/f.$$ and echo $$ > pidfile were all silently rewritten between
what the playbook said and what ran, with the log recording the rewritten form. The flag
already existed on the executor for loop bodies; this exposes it per command.

wait_for_exit: true returns when a command has actually finished and reports its
real status. command_timeout is an idle timeout, so a script that pauses looks
complete while its children keep running. Inside a session there is nothing to wait on,
so AttackMate appends ; echo "$?<marker>" and reads until the marker appears. It works
on pty and pipe sessions alike; a non-interactive command needs no marker, since it is
run to completion anyway and already yields a real status.

This is the only option that rewrites the command, so it is opt-in, documented as such,
and rejected in bin mode.

read_timeout bounds a whole read, for a command that never falls silent at all.

  - type: shell
    cmd: ./linpeas.sh
    pty: True
    wait_for_exit: True
    read_timeout: 900

A session command closes or inspects a session from within a playbook. Sessions
otherwise lived until the end of the run, and a playbook had no way to ask whether one
was alive — it could only find out by sending a command into it and failing.

  - type: session
    cmd: status        # or close
    session: foothold
    executor: shell    # or ssh

Returns 0 on success and 1 when the session does not exist or has exited, so it
combines with exit_on_error: False to check a foothold before relying on it. Closing
an ssh session also drops its terminal screen, or a later session reusing the name
inherits it. msf, sliver and browser are not covered: neither has a non-blocking
way to resolve a name to an id, so closing a dead one would hang.

Environment overrides are logged. ATTACKMATE_-prefixed variables silently replaced
playbook variables, so the playbook on disk did not fully determine what ran and nothing
recorded the difference. The names replaced are now logged at startup.

5. Two performance fixes

Both interactive read loops polled with no sleep at all, burning a full core for the
whole command_timeout — and with command_timeout: 0, which
examples/ssh_example.yml uses for linpeas, that is a core pinned for an entire run.
Measured against a real sshd and a real pipe session: 100% of a core before, under 1%
after
.

The ssh loop also decoded each chunk separately, mangling any multi-byte character split
across a chunk boundary. Bytes are now accumulated and decoded once.

Compatibility

The pipe path is otherwise untouched: with pty unset a shell command takes the
original code, and expand_keys defaults to False there. term defaults to vt100
for ssh, matching paramiko's own invoke_shell() default, so untouched ssh playbooks
behave identically. The ssh check_timer timeout semantics are deliberately unchanged,
since existing playbooks are tuned to the current total-budget behaviour.

pty mode is Unix-only and returns a non-zero result on Windows rather than failing at
import — pty/fcntl/termios are imported inside the functions that use them.

Adds pyte as a dependency, for screen mode.

How Has This Been Tested?

Automated — 358 tests pass (uv run --dev pytest test/units -q, ~60 s), of which
~250 are the pre-existing suite, unchanged. Six new test files:

File Covers
test_terminal.py key expansion, ANSI stripping, cursor-movement resolution, the pyte regression, scrollback
test_ptysession.py real pty behaviour: tty, /dev/tty, winsize, TERM, raw mode, prompts, teardown
test_shellexecutor_pty.py executor wiring, schema defaults, pickling
test_session_cleanup.py the listener leak, process groups, cleanup on SystemExit
test_audit_log.py end time, duration, exit status, records surviving failures, $$
test_session_command.py wait_for_exit, read_timeout, the session command, env overrides

The pty tests deliberately avoid depending on an editor, since CI is not guaranteed to
have one. They use only /bin/sh and coreutils, and each isolates one mechanism — every
one fails without this change:

probe pipes (before) pty (after) proves
test -t 0 RESULT=no RESULT=yes a terminal is attached
stty size Inappropriate ioctl 40 100 TIOCSWINSZ, pty_rows/pty_cols
echo hi > /dev/tty No such device or address hi TIOCSCTTY — the sudo mechanism
stty -a icanon echo isig -icanon -echo -isig raw mode

Note the probes assemble their marker with printf 'RESULT=%s\n' yes rather than
echoing it literally: a pty echoes the command back, so a naive
assert 'IS_TTY' in output passes even on the pipe path.

Manual — end to end. examples/pty_example.yml is included and drives both editors
for real, asserting on what each wrote to disk, plus wait_for_exit, session and
substitute_cmd_vars:

$ uv run attackmate examples/pty_example.yml
$ cat /tmp/attackmate_pty_demo.txt
Edited by AttackMate      <- written by nano, via <C-o><CR><C-x>
Hello from AttackMate     <- written by vim, via <ESC>:wq!

Manual — the leak. A session starting nc -l -p <port>; after cleanup the port is
free. It is held open on main today.

Manual — ssh. Verified against a real sshd on loopback: interactive commands, ANSI
stripping, screen: True, the non-interactive path unchanged, and the CPU measurement
quoted above.

Manual — wait_for_exit. On a step that is silent for 6 s then finishes, it returned
at 6.3 s with the real exit status, where the idle timeout returned empty at 2.0 s.

Not tested

sudo and su actually prompting for and accepting a password. That needs a non-root
account, which this environment did not have. The mechanism is verified — /dev/tty is
writable now, which is exactly what those programs read — but the credential flow itself
has not been exercised. Worth a reviewer confirming.

… that says what happened

A shell command is connected to pipes, so the process it starts has no
terminal and no controlling terminal at all. Programs that require one
cannot be driven:

  - vim and nano report "Output is not to a terminal" and never draw
  - sudo, su and ssh read passwords from /dev/tty rather than stdin, so a
    password sent as the next command never reaches them

The documentation already promised otherwise: the sessions page walked
through editing a file with vim in interactive mode, and the shell page
cited opening vim as the motivating example for it. Neither could work.
Interactive mode only limits how long AttackMate waits; the command still
talks to a pipe. Both pages are corrected here.

Setting "pty: True" runs the shell behind a pseudo-terminal. Allocating the
pty is not sufficient on its own: the child also has to adopt it as its
*controlling* terminal (TIOCSCTTY), which is what makes /dev/tty exist and
the password prompts reachable.

Sessions are raw by default (raw: false to opt out). A fresh pty comes up in
the line-editing mode meant for a human at a keyboard, which is invisible for
a local full-screen program - it sets raw mode itself - and fatal when the
session is a transport, because a program on a remote host can never change
the line discipline of a terminal on this one: icanon holds a lone <C-o> in
the local buffer, isig turns <C-c> into a signal against the local shell
instead of sending it onward, and echo puts every command into the output
that error_if and save then see. Note that with icrnl off a full-screen
program needs a real carriage return - send <CR>, not \n, to answer a prompt
inside one; shell command lines are unaffected.

Output is stripped of escape sequences by default, with cursor movements
applied rather than deleted, so "p\x08printf" resolves to "printf" instead of
"pprintf" - text that never appeared on screen and which defeats error_if.
"screen: True" instead renders the emulated screen, the only way to read a
program that paints itself by moving the cursor.

Keystrokes are written as names: <ESC>, <CR>, <TAB>, <UP>, <F1>, <C-x>.
Expansion is on by default for pty commands and off elsewhere, so existing
playbooks keep sending their text verbatim; <LT> gives a literal "<".

Interactive ssh commands already run behind a terminal on the remote host, so
they need no pty, but their output was returned as the raw byte stream. They
now share the same stripping, screen rendering and key expansion. Key
expansion defaults to off there, because such playbooks already exist.

Sessions no longer outlive the run. clean_sessions closed the pty store and
skipped the pipe store, on the grounds that those Popen objects are closed by
the executor - true of an ordinary command, false of a session, which
_exec_cmd deliberately leaves open. A session that started a listener kept it
alive after AttackMate exited, still holding its port, so the next run could
not bind, its reverse shell never connected, and because a shell step's
returncode was hardcoded 0 every step after that was still recorded as a
success. Killing the shell is not enough - `nc -l &` outlives its parent - so
the whole process group is signalled, which required giving sessions their own
group first: without start_new_session they share AttackMate's, and signalling
would take AttackMate down too.

Cleanup now runs in a finally. It sat in the try after _run_commands, so it
ran only on the happy path - and exit_on_error, error_if and the loop
conditions all end a run with exit(1), whose SystemExit is neither an
Exception nor a KeyboardInterrupt and passed straight through this handler and
the one in __main__. The most common way for a run to fail was also the way
that guaranteed a live session was left on the target.

The audit log now records what a step did. Each entry gains end-datetime,
duration-seconds and exit-status. The real status was available in two places
and discarded: proc.returncode after communicate(), and recv_exit_status() on
the ssh path, which was never called - so ssh's only failure signal was "wrote
something to stderr", which calls a noisy successful command a failure and a
silent failure a success. Inside a live session no true status exists, and
that is recorded as null rather than faked as 0. Making a real status
authoritative would break playbooks that have always passed, so returncode
keeps its historical value unless a command sets use_exit_code: true.

Three further gaps in the same area: a step killed by an uncaught exception
was never written to the log at all, because the record was produced after
_exec_cmd returned - the artifact showed a playbook that simply stopped, with
no trace of the step that ended it, which is how a dropped reverse shell
presents; the start timestamp was bound inside that same try, so a failure in
log_command turned into an UnboundLocalError in the error handler; and a step
skipped by only_if returned before any logging, so it was absent entirely,
which reads exactly like a step that was never in the playbook.

Dead sessions fail cleanly on both paths. The pipe path wrote into a closed
shell and died of an uncaught BrokenPipeError, taking the playbook with it.

Commands can opt out of variable templating with substitute_cmd_vars: false.
string.Template collapses $$ to a single $, and in a shell $$ is the process
id, so `kill -9 $$`, `/tmp/f.$$` and `echo $$ > pidfile` were all silently
rewritten between what the playbook said and what ran, with the log recording
the rewritten form.

wait_for_exit returns when a command has actually finished and reports its
real status. command_timeout is an idle timeout, so a script that pauses looks
complete while its children keep running. Inside a session there is nothing to
wait on, so AttackMate appends `; echo "$?<marker>"` and reads until the
marker - the one option here that rewrites a command, hence opt-in and
rejected in bin mode. It works on pty and pipe sessions alike; a
non-interactive command needs no marker, since communicate() already runs it
to completion and yields a real status. read_timeout bounds a whole read, for
a command that never falls silent at all.

A new "session" command closes or inspects a session from a playbook:

    - type: session
      cmd: status        # or close
      session: foothold
      executor: shell    # or ssh

Sessions otherwise lived until the end of the run, and a playbook had no way
to ask whether one was alive - it could only find out by sending a command
into it and failing. Closing an ssh session also drops its terminal screen, or
a later session reusing the name inherits it. msf, sliver and browser are not
covered: neither has a non-blocking way to resolve a name to an id.

ATTACKMATE_-prefixed environment variables silently replaced playbook
variables, so the playbook on disk did not fully determine what ran and
nothing recorded the difference. The names replaced are now logged at startup.

Both interactive read loops polled with no sleep, burning a full core for the
whole command_timeout - with "command_timeout: 0", which examples/ssh_example.yml
uses, that is a core pinned for an entire run. Measured against a real sshd and
a real pipe session: 100% of a core before, under 1% after. The ssh loop also
decoded each chunk separately, mangling multi-byte characters split across a
chunk boundary.

Three details that are easy to get wrong, each covered by a test:

  - pyte 0.8.2 raises TypeError on real vim output, which emits a private SGR
    sequence its select_graphic_rendition does not accept.
  - Closing hangs up the terminal before signalling. An interactive shell
    ignores SIGTERM, so signalling first meant waiting out the timeout on every
    session teardown before falling back to SIGKILL.
  - Stripping must not trim trailing whitespace. The default prompts ('$ ',
    '# ', '> ') all end in a space, so trimming would stop every prompt from
    ever matching.

The pipe-based path is otherwise untouched: with pty unset a shell command
takes the original code, and expand_keys defaults to False there. term
defaults to vt100 for ssh, matching paramiko's own invoke_shell default. pty
mode is Unix-only and returns a non-zero result on Windows rather than failing
at import.

Adds pyte as a dependency, and examples/pty_example.yml, which drives vim and
nano end to end and asserts on what each editor wrote to disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FH9hA3LKKbg6yB3kUEMm7a
@whotwagner

Copy link
Copy Markdown
Contributor

can you please also add the sections to the description according to our pr-template:

# Task
<!-- Please add link a relevant issue or task -->

# Description
<!-- Please include a summary of the change -->
<!-- Any details that you think are important to review this PR? -->
<!-- Are there other PRs related to this one? -->

# How Has This Been Tested?
<!-- Please describe how you tested your changes -->

# Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply -->

- [ ] This Pull-Request goes to the **development** branch.
- [ ] I have successfully run prek locally.
- [ ] I have added tests to cover my changes.
- [ ] I have linked the issue-id to the task-description.
- [ ] I have performed a self-review of my own code.

Please also open issues that this pr addresses. So that we can, if necessary, discuss the issues.

@VainXploits

VainXploits commented Sep 14, 2026

Copy link
Copy Markdown
Author

Hey @whotwagner . I just updated it, please check it out, I'll be opening the issues in just a bit, I've also been working on them, and I should push a stacked PR or make a new one once this is merged into the development branch. I didn't want to overwhelm you guys with so many PRs. All 6 of the issues mentioned in this PR have been fixed and implemented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants