From 01f5cf08d74aa9842980e3820f651b5e7f74f8d1 Mon Sep 17 00:00:00 2001 From: Eapp1e <163811520+Eapp1e@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:02:18 +0800 Subject: [PATCH] feat(errno): resolve errno values for the requested context (#2788) (#2789) `pwn errno` only ever consulted the host's errno module, so numbers from a target of a different architecture were reported as unknown (131 is ECONNRESET on mips but unused here). Add -c/--context, built on the same context_arg helper as asm, constgrep and cyclic: number and name lookups now resolve against pwnlib.constants for the requested os/arch, and --list/--search honour the flag too. --search also matches errno names, so the flag stays useful on hosts whose strerror cannot spell out the target's codes. Covered with three inline doctests, as TESTING.md asks for. --- CHANGELOG.md | 2 + docs/source/commandline.rst | 7 +- pwnlib/commandline/errno.py | 137 ++++++++++++++++++++++++++++++------ 3 files changed, 123 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf25e05..44a1d5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ The table below shows which release corresponds to each branch, and what date th ## 5.0.0 (`dev`) - [#2780][2780] fix(encoders): handle unavailable xor encodings and fallback +- [#2789][2789] feat(errno): support `-c/--context` to look up errno values for other architectures - [#2784][2784] docs: put plain doctest blocks in the default group for Sphinx 9 - [#2762][2762] fix(srop): correct amd64 SigreturnFrame `uc_sigmask` offset - [#2753][2753] docs(args): clarify reserved args (DEBUG/NOASLR) map to context, not args @@ -233,6 +234,7 @@ The table below shows which release corresponds to each branch, and what date th [2768]: https://github.com/Gallopsled/pwntools/pull/2768 [2769]: https://github.com/Gallopsled/pwntools/pull/2769 [2780]: https://github.com/Gallopsled/pwntools/pull/2780 +[2789]: https://github.com/Gallopsled/pwntools/pull/2789 [2750]: https://github.com/Gallopsled/pwntools/pull/2750 [2761]: https://github.com/Gallopsled/pwntools/pull/2761 [2727]: https://github.com/Gallopsled/pwntools/pull/2727 diff --git a/docs/source/commandline.rst b/docs/source/commandline.rst index 5c89bc21..3be8408d 100644 --- a/docs/source/commandline.rst +++ b/docs/source/commandline.rst @@ -5,7 +5,8 @@ .. testcleanup:: * - context.defaults.copy = old + context.defaults.clear() + context.defaults.update(old) Command Line Tools ======================== @@ -18,3 +19,7 @@ If these tools do not appear to be installed, make sure that you have added ``~/ .. autoprogram:: pwnlib.commandline.main:parser :prog: pwn + +.. only:: doctest + + .. automodule:: pwnlib.commandline.errno diff --git a/pwnlib/commandline/errno.py b/pwnlib/commandline/errno.py index 6803711b..d02380bf 100644 --- a/pwnlib/commandline/errno.py +++ b/pwnlib/commandline/errno.py @@ -1,8 +1,23 @@ +"""Look up ``errno`` values for the architecture selected by ``-c``. + +The examples below run in the documentation doctest suite. + +>>> from pwnlib.context import context +>>> from pwnlib.commandline.errno import _context_table +>>> with context.local(arch='amd64', os='linux'): +... _context_table()['ECONNRESET'] +104 +>>> with context.local(arch='mips', os='linux'): +... _context_table()['ECONNRESET'] +131 +""" from argparse import Namespace from typing import Generator import os import errno +import pwnlib.constants as constants +from pwnlib.context import context from pwnlib.commandline import common parser = common.parser_commands.add_parser( @@ -21,16 +36,76 @@ parser.add_argument( ) parser.add_argument( '-s', '--search', action='store_true', - help='Treat the argument as a perror substring and print every matching errno.' + help='Treat the argument as an errno name or perror substring and print every match.' +) +parser.add_argument( + '-c', '--context', + metavar = 'context', + type = common.context_arg, + help = 'Look the errno values up for this architecture or os, e.g. `-c aarch64`.', ) -def _iter_known_errnos() -> Generator[tuple[int, str, str], None, None]: - """Yield ``(value, name, message)`` tuples for every errno code visible to - the standard library on this platform, sorted by numeric value. +def _host_table() -> dict[str, int]: + """Return ``{name: value}`` for every errno the host standard library knows.""" + return {name: getattr(errno, name) for name in dir(errno) + if name.isupper() and isinstance(getattr(errno, name), int)} + + +def _context_table() -> dict[str, int]: + """Return ``{name: value}`` for the errno numbers ``context`` uses.""" + if context.os == 'freebsd': + module = getattr(constants, 'freebsd', None) + else: + module = getattr(getattr(constants, context.os, None), context.arch, None) + + if module is None: + return {} + + table = {} + + for name in _host_table(): + value = getattr(module, name, None) + if value is not None: + table[name] = int(getattr(value, 'value', value)) + + return table + + +def _message(name: str) -> str: + """Return the perror string of ``name`` according to the host, or ``''``.""" + value = getattr(errno, name, None) + if not isinstance(value, int): + return '' + + try: + return os.strerror(value) + except ValueError: + return '' + + +def _iter_known_errnos(table: dict[str, int]) -> Generator[tuple[int, str, str], None, None]: + """Yield ``(value, name, message)`` tuples sorted by numeric value. + + Aliases sharing a number are reported once: the canonical name from + `errno.errorcode` wins when it maps to the same value, otherwise the + first name alphabetically does. + + >>> with context.local(arch='aarch64', os='linux'): + ... [(value, name) for value, name, _ in _iter_known_errnos(_context_table())][:3] + [(1, 'EPERM'), (2, 'ENOENT'), (3, 'ESRCH')] """ - for value, name in sorted(errno.errorcode.items()): - yield value, name, os.strerror(value) + # ``errno.errorcode`` is the canonical name for a number, so prefer it over the + # alphabetically first alias to keep the default listing identical to before. + canonical = {value: name for value, name in errno.errorcode.items() if table.get(name) == value} + seen = set() + + for name, value in sorted(table.items(), key=lambda item: (item[1], item[0])): + if value in seen: + continue + seen.add(value) + name = canonical.get(value, name) + yield value, name, _message(name) def _print_errno(value: int, name: str | None = None, message: str | None = None) -> None: @@ -42,18 +117,37 @@ def _print_errno(value: int, name: str | None = None, message: str | None = None print('#define %s %d' % (name, value)) else: print('#define %d' % value) - print(message) + if message: + print(message) -def _search_errnos(needle: str) -> list[tuple[int, str, str]]: - """Return errnos whose ``strerror`` text contains ``needle`` (case-insensitive).""" +def _search_errnos(needle: str, table: dict[str, int]) -> list[tuple[int, str, str]]: + """Return errnos whose name or ``strerror`` text contains ``needle``. + + The match is case-insensitive, so ``pwn errno -c mips -s conn`` finds the + connection-oriented errnos even when the host cannot spell them out. + + >>> with context.local(arch='mips', os='linux'): + ... [(value, name) for value, name, _ in _search_errnos('econnreset', _context_table())] + [(131, 'ECONNRESET')] + """ needle = needle.lower() - return [(v, n, m) for v, n, m in _iter_known_errnos() if needle in m.lower()] + return [(v, n, m) for v, n, m in _iter_known_errnos(table) + if needle in n.lower() or needle in m.lower()] def main(args: Namespace) -> None: + if args.context is None: + table = _host_table() + else: + table = _context_table() + + if not table: + print('No errno values known for %s/%s' % (context.os, context.arch)) + return + if args.list: - for value, name, message in _iter_known_errnos(): + for value, name, message in _iter_known_errnos(table): print('%-3d %-10s %s' % (value, name, message)) return @@ -61,7 +155,7 @@ def main(args: Namespace) -> None: parser.error('the following arguments are required: error (or pass --list)') if args.search: - matches = _search_errnos(args.error) + matches = _search_errnos(args.error, table) if not matches: print("No errno matched %r" % args.error) return @@ -79,22 +173,20 @@ def main(args: Namespace) -> None: if 0x100000000 - value < 0x200: value = 0x100000000 - value - if value not in errno.errorcode: + name = next((n for n, v in sorted(table.items()) if v == value), None) + + if name is None: print("No errno for %s" % value) return - name = errno.errorcode[value] - except ValueError: - candidate = args.error.upper() + name = args.error.upper() + value = table.get(name) - if hasattr(errno, candidate): - name = candidate - value = getattr(errno, name) - else: + if value is None: # Fall back to a substring search against perror messages so # ``pwn errno 'Cannot allocate memory'`` resolves to ENOMEM. - matches = _search_errnos(args.error) + matches = _search_errnos(args.error, table) if not matches: print("No errno for %s" % args.error) return @@ -104,8 +196,9 @@ def main(args: Namespace) -> None: print() return - _print_errno(value, name) + _print_errno(value, name, _message(name)) if __name__ == '__main__': common.main(__file__, main) +