feat(shell,ssh): terminal support, session lifetime, and an audit log… - #261
Open
VainXploits wants to merge 1 commit into
Open
VainXploits wants to merge 1 commit into
VainXploits wants to merge 1 commit into
Conversation
… 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
Contributor
|
can you please also add the sections to the description according to our pr-template: Please also open issues that this pr addresses. So that we can, if necessary, discuss the issues. |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Task
Addresses:
$$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.jsonsaywhat a step actually did.
1. Programs that require a terminal
A
shellcommand is connected to pipes, so what it starts has no terminal and nocontrolling terminal at all:
vimandnanoreport "Output is not to a terminal" and never draw a screensudo,suandsshread passwords from/dev/ttyrather than stdin, so apassword sent as the next command never reaches them
The documentation already promised otherwise —
session/index.rstwalked throughediting a file with
vimin interactive mode, andshell.rstcitedvimas themotivating 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: Trueruns the shell behind a pseudo-terminal. Allocating the pty is notsufficient on its own — the child must also adopt it as its controlling terminal
(
TIOCSCTTY), which is what makes/dev/ttyexist.Sessions are raw by default (
raw: falseto opt out). A fresh pty comes up in theline-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:
icanonholds a lone<C-o>or<C-x>in the local line buffer, so it never travelsisigturns<C-c>into a signal against the local shell instead of sending it onwardechoreturns every command, so a session's output contains the commands as well astheir results — and that is what
error_ifandsaveseeWith
icrnloff 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 toprintfrather thanpprintf— text that never appeared on screen and which defeatserror_if.screen: Truerenders the emulated screen instead, the only way to read a program thatpaints 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
sshcommands already run behind a terminal on the remote host, so theyneed 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_sessionsclosed the pty store and skippedthe pipe store, on the grounds that those
Popenobjects are closed by the executor —true of an ordinary command, false of a session, which
_exec_cmddeliberately leavesopen. 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 stillrecorded as a success. One run silently corrupted the next.
Killing the shell is not enough —
nc -l &outlives its parent — so the whole processgroup is signalled. That required giving sessions their own group first: without
start_new_sessiona session shares AttackMate's group, and signalling it would takeAttackMate down too. Ordinary commands are unchanged and still share the parent group.
Cleanup was skipped whenever a run ended badly. It sat in the
tryafter_run_commands, so it ran only on the happy path. This is worse than it sounds:exit_on_error,error_if,error_if_notand the loop conditions all end a run withexit(1), and the resultingSystemExitis neither anExceptionnor aKeyboardInterrupt— 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 alive session was left behind on the target. Cleanup now runs in a
finally, with itsown 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 adropped 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-secondsandexit-status. The real statuswas available in two places and discarded:
proc.returncodeaftercommunicate(), andrecv_exit_status()on the ssh path, which was never called — so ssh's only failuresignal 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
nullrather than faked as0.Making a real status authoritative would break playbooks that have always passed, so
returncode— whatexit_on_erroracts on — keeps its historical value unless acommand sets
use_exit_code: true.Three further gaps in the same area:
record was produced after
_exec_cmdreturned. The artifact showed a playbook thatsimply stopped, with no trace of the step that ended it.
try, so a failure inlog_command—reachable through a non-numeric ssh port — became an
UnboundLocalErrorin the errorhandler.
only_ifreturned 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: falseruns a command exactly as written.string.Templatecollapses
$$into a single$, and in a shell$$is the process id — sokill -9 $$,/tmp/f.$$andecho $$ > pidfilewere all silently rewritten betweenwhat 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: truereturns when a command has actually finished and reports itsreal status.
command_timeoutis an idle timeout, so a script that pauses lookscomplete 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 workson 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
binmode.read_timeoutbounds a whole read, for a command that never falls silent at all.A
sessioncommand closes or inspects a session from within a playbook. Sessionsotherwise 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.
Returns
0on success and1when the session does not exist or has exited, so itcombines with
exit_on_error: Falseto check a foothold before relying on it. Closingan ssh session also drops its terminal screen, or a later session reusing the name
inherits it.
msf,sliverandbrowserare not covered: neither has a non-blockingway to resolve a name to an id, so closing a dead one would hang.
Environment overrides are logged.
ATTACKMATE_-prefixed variables silently replacedplaybook 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 withcommand_timeout: 0, whichexamples/ssh_example.ymluses 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
ptyunset a shell command takes theoriginal code, and
expand_keysdefaults toFalsethere.termdefaults tovt100for ssh, matching paramiko's own
invoke_shell()default, so untouched ssh playbooksbehave identically. The ssh
check_timertimeout semantics are deliberately unchanged,since existing playbooks are tuned to the current total-budget behaviour.
ptymode is Unix-only and returns a non-zero result on Windows rather than failing atimport —
pty/fcntl/termiosare imported inside the functions that use them.Adds
pyteas a dependency, forscreenmode.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:
test_terminal.pytest_ptysession.py/dev/tty, winsize,TERM, raw mode, prompts, teardowntest_shellexecutor_pty.pytest_session_cleanup.pySystemExittest_audit_log.py$$test_session_command.pywait_for_exit,read_timeout, thesessioncommand, env overridesThe pty tests deliberately avoid depending on an editor, since CI is not guaranteed to
have one. They use only
/bin/shand coreutils, and each isolates one mechanism — everyone fails without this change:
test -t 0RESULT=noRESULT=yesstty sizeInappropriate ioctl40 100TIOCSWINSZ,pty_rows/pty_colsecho hi > /dev/ttyNo such device or addresshiTIOCSCTTY— thesudomechanismstty -aicanon echo isig-icanon -echo -isigNote the probes assemble their marker with
printf 'RESULT=%s\n' yesrather thanechoing it literally: a pty echoes the command back, so a naive
assert 'IS_TTY' in outputpasses even on the pipe path.Manual — end to end.
examples/pty_example.ymlis included and drives both editorsfor real, asserting on what each wrote to disk, plus
wait_for_exit,sessionandsubstitute_cmd_vars:Manual — the leak. A session starting
nc -l -p <port>; after cleanup the port isfree. It is held open on
maintoday.Manual — ssh. Verified against a real
sshdon loopback: interactive commands, ANSIstripping,
screen: True, the non-interactive path unchanged, and the CPU measurementquoted above.
Manual —
wait_for_exit. On a step that is silent for 6 s then finishes, it returnedat 6.3 s with the real exit status, where the idle timeout returned empty at 2.0 s.
Not tested
sudoandsuactually prompting for and accepting a password. That needs a non-rootaccount, which this environment did not have. The mechanism is verified —
/dev/ttyiswritable now, which is exactly what those programs read — but the credential flow itself
has not been exercised. Worth a reviewer confirming.