Skip to content

feat(util): add keys module for terminal control sequences (#2368) - #2724

Open
ChrisJr404 wants to merge 5 commits into
Gallopsled:devfrom
ChrisJr404:feature/util-keys
Open

feat(util): add keys module for terminal control sequences (#2368)#2724
ChrisJr404 wants to merge 5 commits into
Gallopsled:devfrom
ChrisJr404:feature/util-keys

Conversation

@ChrisJr404

Copy link
Copy Markdown
Contributor

Closes #2368.

What

Adds pwnlib.util.keys, a tiny new module that gives readable names to the bytes most CTF / exploit scripts otherwise spell as opaque literals when interacting with a tube, e.g.

from pwn import *
from pwnlib.util.keys import CTRL_C, UP, ENTER, ctrl, csi

p = process("./vuln")
p.send(UP)            # was b"\x1b[A"
p.send(CTRL_C)        # was b"\x03"
p.send(ENTER)         # was b"\r"
p.send(ctrl('a'))     # readline: jump to start of line  -> b"\x01"
p.send(csi('2J'))     # clear screen                     -> b"\x1b[2J"

The module exports:

  • All 32 C0 control bytes (NULUS) plus DEL, with CTRL_ACTRL_Z aliases and the symbolic CTRL_BACKSLASH / CTRL_RBRACKET / CTRL_CARET / CTRL_UNDERSCORE.
  • Friendlier aliases BACKSPACE, ENTER, NEWLINE, TAB_KEY, ESCAPE, SPACE.
  • The arrow keys, navigation keys (HOME, END, PAGE_UP, PAGE_DOWN, INSERT, DELETE), and F1F12 xterm sequences.
  • Convenience screen-control sequences CLEAR_SCREEN, CLEAR_LINE.
  • Generators: ctrl(char) for any Ctrl+<x> (letters, plus @[\\]^_?), alt(char) for any Alt/Meta+<x> (str or bytes-like), and csi(rest) to compose ad-hoc CSI escapes.

Why

#2368 (filed by @peace-maker) asks for "some nice enum/abstraction" so that things like Ctrl-C through a socket aren't written as b'\x03'. There's already a place in pwntools where a maintainer left a breadcrumb in code:

https://github.com/Gallopsled/pwntools/blob/dev/pwnlib/tubes/ssh.py#L254

data = [3] # This is ctrl-c

This change replaces the need for those comments — callers can write CTRL_C directly.

Tests

The module ships with doctests for every documented surface. Verified locally:

$ python -c "import doctest, pwnlib.util.keys as m; r = doctest.testmod(m); print(r)"
TestResults(failed=0, attempted=19)

Plus error-path checks (single-char enforcement on ctrl(), type checks on alt() / csi()):

ctrl('ab')  -> ValueError: char must be a single-character string
ctrl(1)     -> ValueError: char must be a single-character string
ctrl('!')   -> ValueError: '!' has no Ctrl-form (use a letter or @[\]^_?)
alt(123)    -> TypeError: char must be str or bytes-like
csi(123)    -> TypeError: rest must be str or bytes-like

Notes

  • The new module is added to pwnlib.util.__all__ and gets a normal Sphinx docs page at docs/source/util/keys.rst, matching the layout of the other pwnlib.util.* modules.
  • No existing modules import it — it's purely additive surface that callers can opt into. Nothing else in the tree changes its behaviour.
  • The function-key sequences pick the canonical xterm encoding (\x1bOP\x1bOS for F1–F4, \x1b[N~ for F5–F12). Real terminals also have alternate forms; if any consumer needs those they can use csi(...) directly.

ChrisJr404 added a commit to ChrisJr404/pwntools that referenced this pull request May 3, 2026

@peace-maker peace-maker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool, this will make exploits dealing with control sequences way easier to read.
Can you add links to references where the different codes are defined please?

A test interacting with QEMU monitor using ctrl('a')+c would be nice to show how to use it.

Comment thread pwnlib/util/keys.py Outdated
elif isinstance(char, (bytes, bytearray, memoryview)):
char = bytes(char)
else:
raise TypeError("alt(): char must be str or bytes-like")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have logic for this in pwnlib.util.packing._need_bytes instead of reimplementing it here.

Comment thread pwnlib/util/keys.py Outdated
if 0x40 <= code <= 0x5f:
return bytes((code & 0x1f,))
if char == '?':
# Ctrl-? is conventionally DEL (0x7f)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you link to some reference for such quirks please?

@peace-maker

Copy link
Copy Markdown
Member

I noticed we already have something like this in the code base in the term module.

_csi_ss3s = {

Can this be reused instead of adding the constants again? @Arusekk you're the term guy :D

@ChrisJr404

Copy link
Copy Markdown
Contributor Author

Added the reference links (ECMA-6/ECMA-48 + the xterm ctlseqs page) to the module docstring, plus a runnable example using ctrl('a')+c for the QEMU monitor case — did it against a local listen/remote pair so it stays in the doctests without needing a real guest.

On reusing term/key.py: that module goes the other way, it decodes bytes coming back from the terminal into Key objects (kc.KEY_* integers), there's no table of sendable sequences in there to pull from. This module is just the encode side. Happy to move the constants somewhere shared if you'd rather they live next to the term code.

…d#2368)

New pwnlib.util.keys module with constants for C0 control characters and
common ANSI/xterm escape sequences (arrow keys, function keys, line/screen
clear), plus generators ctrl(), alt(), and csi(). Lets tube callers send
readable values like CTRL_C / UP / ctrl('a') instead of opaque byte
literals like b'\\x03'.

Closes Gallopsled#2368
The QEMU-monitor example uses listen()/remote(), but the keys.rst
testsetup only pulled in the keys module, so those names were undefined
under the Sphinx doctest run (NameError). Import them explicitly, same
as proc.rst does for process().
Address review feedback:
- alt()/csi() now route str/bytes coercion through
  pwnlib.util.packing._need_bytes instead of hand-rolling latin-1
  encoding.  min_wrong is set past the latin-1 range so the ergonomic
  str form (alt('x'), csi('H')) stays warning-free while >0xff still
  falls back to the shared helper's behaviour.
- Link the xterm FAQ for the Ctrl-? -> DEL (0x7f) rubout convention.
@ChrisJr404

Copy link
Copy Markdown
Contributor Author

@peace-maker I dug into term/key.py properly this time to see if the encoder could just be derived from what's already there, since you're right that the overlap looks obvious at first glance. Short version: the data in the term module isn't a table of sendable sequences, so I don't think there's a clean reuse — but let me show the specific reason rather than just asserting it, and I'm happy to meet in the middle if you'd still prefer them to live together.

L306 is _csi_ss3s, and the sibling table is _csi_funcs (L342). Both are decoder tables:

_csi_ss3s = {
    'A': (kc.TYPE_KEYSYM, kc.KEY_UP),
    'B': (kc.TYPE_KEYSYM, kc.KEY_DOWN),
    ...
}
_csi_funcs = {
    3 : (kc.TYPE_KEYSYM, kc.KEY_DELETE),
    5 : (kc.TYPE_KEYSYM, kc.KEY_PAGEUP),
    ...
}

Two things make them awkward as a source of truth for the send side:

  1. They're keyed on fragments, not whole sequences. _csi_ss3s is keyed by the single final byte ('A', 'B', …) and _csi_funcs by the numeric parameter (3, 5, …). Neither stores the ESC[ / ESC O introducer or the ~ terminator — that framing lives in the parser (_parse_csi, and the offset=2 in _peekkey_csi/_peekkey_ss3). So to turn KEY_PAGEUP back into b'\x1b[5~' I'd have to re-add the prefix, the number formatting and the terminator by hand — i.e. re-hardcode exactly the bytes I'm trying to avoid hardcoding, just spread across a reverse-lookup.

  2. The decoder is deliberately many-to-one, so it can't tell you which sequence to send. The same _csi_ss3s table is consulted for both the CSI form (ESC[A, via _peekkey_csi) and the SS3 form (ESC O A, via _peekkey_ss3) — that's the right call for a parser, it should accept both. But an encoder has to pick one canonical form, and that choice genuinely differs per key: this module sends arrows as CSI (UP = b'\x1b[A') but F1–F4 as SS3 (F1 = b'\x1bOP'), matching what xterm actually emits. That decision isn't present in the decoder tables at all.

On top of that, the decoder's real source of truth at runtime is terminfo — _init_ti_table() pulls the actual sequences from termcap.get(name), i.e. it asks the local terminal. An exploit sending keystrokes into a remote program can't do that, which is why the send side has to commit to fixed xterm literals. Different direction, different constraint. And the bulk of this module (the C0 controls, every CTRL_*, ctrl()/alt()/csi()) has no counterpart table in term anyway — the decoder handles C0 with inline branches in _peek_simple, not a table.

So my read is that sharing the tables would make both sides harder to follow rather than removing duplication. That said, I'm not attached to keeping it separate: if you and @Arusekk would rather these live next to the term code, or want me to add a small keysym → canonical-bytes table that the encoder owns and the parser could optionally cross-check in a test, I'm glad to do either — just say which you'd prefer.

While I was in here I also took care of your other two notes: alt()/csi() now go through pwnlib.util.packing._need_bytes instead of hand-rolling the latin-1 encode (I bump min_wrong past 0xff so the normal alt('x') call stays warning-free), and I linked the xterm FAQ for the Ctrl-? → DEL rubout convention.

Last thing — the red CI was self-inflicted, not the flaky libcdb/network doctests: the new QEMU example calls listen()/remote(), but the keys.rst testsetup only imported the keys module, so they were undefined under the sphinx doctest run. Fixed by importing them in the testsetup the same way proc.rst does for process().

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.

Add helper to generate tty control sequence characters

2 participants