flaghunter 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.
- flaghunter-0.1.0/LICENSE +21 -0
- flaghunter-0.1.0/PKG-INFO +64 -0
- flaghunter-0.1.0/README.md +44 -0
- flaghunter-0.1.0/pyproject.toml +34 -0
- flaghunter-0.1.0/setup.cfg +4 -0
- flaghunter-0.1.0/src/flaghunter/__init__.py +6 -0
- flaghunter-0.1.0/src/flaghunter/cli.py +61 -0
- flaghunter-0.1.0/src/flaghunter/pcaphunt.py +178 -0
- flaghunter-0.1.0/src/flaghunter/solver.py +123 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/PKG-INFO +64 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/SOURCES.txt +14 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/dependency_links.txt +1 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/entry_points.txt +2 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/requires.txt +1 -0
- flaghunter-0.1.0/src/flaghunter.egg-info/top_level.txt +1 -0
- flaghunter-0.1.0/tests/test_flaghunter.py +106 -0
flaghunter-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jake Burre
|
|
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.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flaghunter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CTF crypto autosolver and pcap hunter. Point it at a challenge file or packet capture and it runs a full attack battery for you.
|
|
5
|
+
Author: Jake Burre
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/burrejak22/flaghunter
|
|
8
|
+
Project-URL: Repository, https://github.com/burrejak22/flaghunter
|
|
9
|
+
Keywords: ctf,crypto,pcap,forensics,security,codebreakers
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: cryptsmith>=0.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# flaghunter
|
|
22
|
+
|
|
23
|
+
Point it at a CTF crypto challenge or a packet capture and it runs a full attack battery for you, then writes up what it found.
|
|
24
|
+
|
|
25
|
+
Built on [cryptsmith](https://github.com/burrejak22/cryptsmith), which does all the crypto and pcap heavy lifting. flaghunter is the analyst on top.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install flaghunter
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# throw a crypto challenge file at it
|
|
37
|
+
flaghunter solve challenge.txt
|
|
38
|
+
|
|
39
|
+
# hunt a pcap for enrollment keys, flags, and obfuscated blobs
|
|
40
|
+
flaghunter pcap capture.pcap
|
|
41
|
+
|
|
42
|
+
# custom token pattern, json output
|
|
43
|
+
flaghunter pcap capture.pcap --pattern "FLAG\{[^}]+\}" --format json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What the pcap hunter does
|
|
47
|
+
|
|
48
|
+
1. Reassembles TCP streams from the capture
|
|
49
|
+
2. Carves printable strings out of every stream
|
|
50
|
+
3. Hunts tokens matching your pattern (default: 12 char alpha strings, like enrollment keys)
|
|
51
|
+
4. Scores every stream for entropy, then runs single byte xor, repeating key xor, caesar, and layered base64/hex decoding on anything suspicious
|
|
52
|
+
5. Writes a markdown report with stream labels, findings, and decoded candidates
|
|
53
|
+
|
|
54
|
+
## What the solver does
|
|
55
|
+
|
|
56
|
+
Reads a challenge file and runs the battery: encoding layers, xor attacks, classical ciphers, hash identification. Ranks everything by english score so the real answer floats to the top.
|
|
57
|
+
|
|
58
|
+
## Example
|
|
59
|
+
|
|
60
|
+
Say a capture has a server checking a 12 character alpha enrollment key, and the key is xor obfuscated in the traffic. flaghunter finds the stream, breaks the xor, and hands you the key.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# flaghunter
|
|
2
|
+
|
|
3
|
+
Point it at a CTF crypto challenge or a packet capture and it runs a full attack battery for you, then writes up what it found.
|
|
4
|
+
|
|
5
|
+
Built on [cryptsmith](https://github.com/burrejak22/cryptsmith), which does all the crypto and pcap heavy lifting. flaghunter is the analyst on top.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install flaghunter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# throw a crypto challenge file at it
|
|
17
|
+
flaghunter solve challenge.txt
|
|
18
|
+
|
|
19
|
+
# hunt a pcap for enrollment keys, flags, and obfuscated blobs
|
|
20
|
+
flaghunter pcap capture.pcap
|
|
21
|
+
|
|
22
|
+
# custom token pattern, json output
|
|
23
|
+
flaghunter pcap capture.pcap --pattern "FLAG\{[^}]+\}" --format json
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What the pcap hunter does
|
|
27
|
+
|
|
28
|
+
1. Reassembles TCP streams from the capture
|
|
29
|
+
2. Carves printable strings out of every stream
|
|
30
|
+
3. Hunts tokens matching your pattern (default: 12 char alpha strings, like enrollment keys)
|
|
31
|
+
4. Scores every stream for entropy, then runs single byte xor, repeating key xor, caesar, and layered base64/hex decoding on anything suspicious
|
|
32
|
+
5. Writes a markdown report with stream labels, findings, and decoded candidates
|
|
33
|
+
|
|
34
|
+
## What the solver does
|
|
35
|
+
|
|
36
|
+
Reads a challenge file and runs the battery: encoding layers, xor attacks, classical ciphers, hash identification. Ranks everything by english score so the real answer floats to the top.
|
|
37
|
+
|
|
38
|
+
## Example
|
|
39
|
+
|
|
40
|
+
Say a capture has a server checking a 12 character alpha enrollment key, and the key is xor obfuscated in the traffic. flaghunter finds the stream, breaks the xor, and hands you the key.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flaghunter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CTF crypto autosolver and pcap hunter. Point it at a challenge file or packet capture and it runs a full attack battery for you."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Jake Burre" }]
|
|
13
|
+
keywords = ["ctf", "crypto", "pcap", "forensics", "security", "codebreakers"]
|
|
14
|
+
dependencies = ["cryptsmith>=0.1.0"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Information Technology",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
flaghunter = "flaghunter.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/burrejak22/flaghunter"
|
|
28
|
+
Repository = "https://github.com/burrejak22/flaghunter"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""flaghunter command line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .pcaphunt import hunt_pcap, render_json, render_markdown
|
|
6
|
+
from .solver import render_markdown as solve_markdown
|
|
7
|
+
from .solver import solve_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cmd_solve(args: argparse.Namespace) -> int:
|
|
11
|
+
report = solve_file(args.file)
|
|
12
|
+
print(solve_markdown(report))
|
|
13
|
+
return 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _cmd_pcap(args: argparse.Namespace) -> int:
|
|
17
|
+
try:
|
|
18
|
+
report = hunt_pcap(args.file, key_len=args.key_len, pattern=args.pattern)
|
|
19
|
+
except FileNotFoundError:
|
|
20
|
+
print(f"error: file not found: {args.file}", file=sys.stderr)
|
|
21
|
+
return 1
|
|
22
|
+
except ValueError as e:
|
|
23
|
+
print(f"error: {e}", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
if args.format == "json":
|
|
26
|
+
print(render_json(report))
|
|
27
|
+
else:
|
|
28
|
+
print(render_markdown(report))
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="flaghunter",
|
|
35
|
+
description="CTF crypto autosolver and pcap hunter. Runs the attack battery so you don't have to.")
|
|
36
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
solve = sub.add_parser("solve", help="run the attack battery against a challenge file")
|
|
39
|
+
solve.add_argument("file", help="challenge file to analyze")
|
|
40
|
+
solve.set_defaults(func=_cmd_solve)
|
|
41
|
+
|
|
42
|
+
pcap_cmd = sub.add_parser("pcap", help="hunt a packet capture for tokens and obfuscated blobs")
|
|
43
|
+
pcap_cmd.add_argument("file", help="pcap file to analyze")
|
|
44
|
+
pcap_cmd.add_argument("--key-len", type=int, default=12,
|
|
45
|
+
help="length of alpha tokens to hunt (default 12)")
|
|
46
|
+
pcap_cmd.add_argument("--pattern", default=None,
|
|
47
|
+
help="custom regex for token hunting, overrides --key-len")
|
|
48
|
+
pcap_cmd.add_argument("--format", choices=["md", "json"], default="md",
|
|
49
|
+
help="report format (default md)")
|
|
50
|
+
pcap_cmd.set_defaults(func=_cmd_pcap)
|
|
51
|
+
return parser
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
parser = build_parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
return args.func(args)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
sys.exit(main())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Pcap hunting: reassemble streams, find tokens, break obfuscated blobs.
|
|
2
|
+
|
|
3
|
+
The main event for captures with custom protocols and hidden keys, like a
|
|
4
|
+
distribution server checking a 12 character alpha enrollment key.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from cryptsmith import analysis, pcap, xor
|
|
10
|
+
from cryptsmith import encoding as enc
|
|
11
|
+
|
|
12
|
+
from .solver import analyze_blob
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StreamFinding:
|
|
17
|
+
stream: str
|
|
18
|
+
kind: str
|
|
19
|
+
detail: str
|
|
20
|
+
value: str
|
|
21
|
+
score: float = 0.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PcapReport:
|
|
26
|
+
source: str
|
|
27
|
+
streams: int
|
|
28
|
+
packets: int
|
|
29
|
+
findings: list[StreamFinding] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _summarize_stream(s: pcap.TCPStream) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"a_to_b_bytes": len(s.a_to_b),
|
|
35
|
+
"b_to_a_bytes": len(s.b_to_a),
|
|
36
|
+
"a_to_b_entropy": round(analysis.shannon_entropy(s.a_to_b), 2) if s.a_to_b else 0.0,
|
|
37
|
+
"b_to_a_entropy": round(analysis.shannon_entropy(s.b_to_a), 2) if s.b_to_a else 0.0,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# words that suggest the decode is real protocol or challenge text, not luck
|
|
42
|
+
_CONTEXT_KEYWORDS = {
|
|
43
|
+
"enroll", "key", "flag", "auth", "login", "password", "passwd", "token",
|
|
44
|
+
"secret", "challenge", "session", "crypto", "decrypt", "encrypt", "admin",
|
|
45
|
+
"server", "client", "hello", "welcome", "granted", "denied", "payload",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _context_score(data: bytes) -> float:
|
|
50
|
+
"""Score a decode context, tolerant of binary framing.
|
|
51
|
+
|
|
52
|
+
Plain english scoring plus a bonus when the context contains security
|
|
53
|
+
flavored words, which is a strong hint the xor key is right.
|
|
54
|
+
"""
|
|
55
|
+
text = "".join(chr(b) for b in data if 32 <= b < 127)
|
|
56
|
+
if len(text) < 8:
|
|
57
|
+
return float("-inf")
|
|
58
|
+
score = analysis.english_score(text)
|
|
59
|
+
low = text.lower()
|
|
60
|
+
if any(w in low for w in _CONTEXT_KEYWORDS):
|
|
61
|
+
score += 50.0
|
|
62
|
+
return score
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hunt_pcap(path: str, key_len: int = 12, pattern: str | None = None,
|
|
66
|
+
min_blob: int = 16) -> PcapReport:
|
|
67
|
+
"""Analyze a pcap: token hunt plus attack battery on suspicious blobs."""
|
|
68
|
+
reader = pcap.PcapReader(path)
|
|
69
|
+
packets = sum(1 for _ in reader.packets())
|
|
70
|
+
streams = pcap.reassemble_streams(pcap.PcapReader(path))
|
|
71
|
+
report = PcapReport(source=path, streams=len(streams), packets=packets)
|
|
72
|
+
|
|
73
|
+
token_hunter = (lambda d: pcap.hunt_pattern(d, pattern)) if pattern else (
|
|
74
|
+
lambda d: pcap.hunt_alpha_tokens(d, length=key_len))
|
|
75
|
+
|
|
76
|
+
for s in streams:
|
|
77
|
+
payload = s.payload
|
|
78
|
+
|
|
79
|
+
# 1. token hunt on the raw payload
|
|
80
|
+
for token in token_hunter(payload):
|
|
81
|
+
report.findings.append(StreamFinding(
|
|
82
|
+
stream=s.label, kind="token",
|
|
83
|
+
detail=f"candidate {'custom pattern' if pattern else f'{key_len} char alpha'} token",
|
|
84
|
+
value=token, score=100.0))
|
|
85
|
+
|
|
86
|
+
# 2. interesting strings
|
|
87
|
+
strings = pcap.carve_strings(payload, min_len=6)
|
|
88
|
+
for st in strings:
|
|
89
|
+
for token in token_hunter(st.encode("latin1")):
|
|
90
|
+
if not any(f.value == token for f in report.findings):
|
|
91
|
+
report.findings.append(StreamFinding(
|
|
92
|
+
stream=s.label, kind="token",
|
|
93
|
+
detail="token inside carved string", value=token, score=90.0))
|
|
94
|
+
|
|
95
|
+
# 3. attack battery per direction: encodings on strings, xor on opaque runs
|
|
96
|
+
for direction, blob in (("a->b", s.a_to_b), ("b->a", s.b_to_a)):
|
|
97
|
+
if len(blob) < min_blob:
|
|
98
|
+
continue
|
|
99
|
+
for st in pcap.carve_strings(blob, min_len=12):
|
|
100
|
+
for chain, decoded in enc.smart_decode(st.encode("latin1")):
|
|
101
|
+
try:
|
|
102
|
+
text = decoded.decode("utf-8", errors="strict").strip()
|
|
103
|
+
except Exception:
|
|
104
|
+
continue
|
|
105
|
+
if len(text) >= 4 and all(32 <= ord(c) < 127 or c in " \t" for c in text):
|
|
106
|
+
for token in token_hunter(text.encode("latin1")):
|
|
107
|
+
report.findings.append(StreamFinding(
|
|
108
|
+
stream=s.label, kind="decoded-token",
|
|
109
|
+
detail=f"token after {chain} decode ({direction})",
|
|
110
|
+
value=token, score=95.0))
|
|
111
|
+
# xor obfuscated token scan: finds 12+ alpha runs under any single
|
|
112
|
+
# byte xor key, even buried in mixed binary. O(n) bitmask scan.
|
|
113
|
+
# near miss keys cluster at the same offset, so group them into
|
|
114
|
+
# one finding per offset and let the analyst pick.
|
|
115
|
+
groups: dict[int, list[tuple[float, int, str]]] = {}
|
|
116
|
+
for offset, key, token in pcap.xor_token_scan(blob, min_length=key_len):
|
|
117
|
+
ctx = blob[max(0, offset - 24):offset + len(token) + 24]
|
|
118
|
+
ctx_pt = xor.xor_bytes(ctx, bytes([key]))
|
|
119
|
+
score = _context_score(ctx_pt)
|
|
120
|
+
groups.setdefault(offset, []).append((score, key, token))
|
|
121
|
+
for offset, cands in groups.items():
|
|
122
|
+
cands.sort(key=lambda c: -c[0])
|
|
123
|
+
lines = [f"0x{k:02x}: {t}" for _, k, t in cands]
|
|
124
|
+
best = cands[0][0]
|
|
125
|
+
report.findings.append(StreamFinding(
|
|
126
|
+
stream=s.label, kind="xor-token",
|
|
127
|
+
detail=(f"{len(cands)} xor keys yield {key_len}+ alpha runs "
|
|
128
|
+
f"at offset {offset} ({direction})"),
|
|
129
|
+
value="\n".join(lines), score=max(best, 1.0)))
|
|
130
|
+
# full solver battery as a last resort, strict threshold
|
|
131
|
+
if analysis.shannon_entropy(blob) > 5.5 and len(blob) >= 32:
|
|
132
|
+
for f in analyze_blob(blob, max_findings=3):
|
|
133
|
+
if f.score > 40 and f.plaintext:
|
|
134
|
+
report.findings.append(StreamFinding(
|
|
135
|
+
stream=s.label, kind=f"auto-{f.method}",
|
|
136
|
+
detail=f"{f.detail} ({direction})",
|
|
137
|
+
value=f.plaintext[:200], score=min(f.score, 80.0)))
|
|
138
|
+
|
|
139
|
+
# dedupe, keep highest score per (stream, kind, value)
|
|
140
|
+
seen: dict[tuple, StreamFinding] = {}
|
|
141
|
+
for f in report.findings:
|
|
142
|
+
k = (f.stream, f.kind, f.value)
|
|
143
|
+
if k not in seen or f.score > seen[k].score:
|
|
144
|
+
seen[k] = f
|
|
145
|
+
report.findings = sorted(seen.values(), key=lambda f: -f.score)
|
|
146
|
+
return report
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_markdown(report: PcapReport) -> str:
|
|
150
|
+
lines = ["# flaghunter pcap report", "",
|
|
151
|
+
f"source: `{report.source}`",
|
|
152
|
+
f"packets: {report.packets}, tcp streams: {report.streams}", ""]
|
|
153
|
+
if not report.findings:
|
|
154
|
+
lines.append("no tokens or decodable blobs found.")
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
lines.append(f"## findings ({len(report.findings)})")
|
|
157
|
+
lines.append("")
|
|
158
|
+
for i, f in enumerate(report.findings, 1):
|
|
159
|
+
lines.append(f"### {i}. [{f.kind}] {f.detail}")
|
|
160
|
+
lines.append(f"stream: `{f.stream}`")
|
|
161
|
+
lines.append("```")
|
|
162
|
+
lines.append(f.value)
|
|
163
|
+
lines.append("```")
|
|
164
|
+
lines.append("")
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_json(report: PcapReport) -> str:
|
|
169
|
+
return json.dumps({
|
|
170
|
+
"source": report.source,
|
|
171
|
+
"packets": report.packets,
|
|
172
|
+
"streams": report.streams,
|
|
173
|
+
"findings": [
|
|
174
|
+
{"stream": f.stream, "kind": f.kind, "detail": f.detail,
|
|
175
|
+
"value": f.value, "score": round(f.score, 1)}
|
|
176
|
+
for f in report.findings
|
|
177
|
+
],
|
|
178
|
+
}, indent=2)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Attack battery for crypto challenge files.
|
|
2
|
+
|
|
3
|
+
Reads a challenge blob and runs every cheap attack, ranking results so the
|
|
4
|
+
real answer floats to the top.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from cryptsmith import analysis, classical, encoding, hashid, xor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Finding:
|
|
13
|
+
method: str
|
|
14
|
+
detail: str
|
|
15
|
+
plaintext: str
|
|
16
|
+
score: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SolveReport:
|
|
21
|
+
source: str
|
|
22
|
+
findings: list[Finding] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def top(self, n: int = 10) -> list[Finding]:
|
|
25
|
+
return sorted(self.findings, key=lambda f: -f.score)[:n]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _printable(data: bytes) -> str | None:
|
|
29
|
+
try:
|
|
30
|
+
text = data.decode("utf-8")
|
|
31
|
+
except Exception:
|
|
32
|
+
try:
|
|
33
|
+
text = data.decode("latin1")
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
if not text or sum(32 <= ord(c) < 127 or c in "\n\r\t" for c in text) / len(text) < 0.85:
|
|
37
|
+
return None
|
|
38
|
+
return text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def analyze_blob(data: bytes, max_findings: int = 25) -> list[Finding]:
|
|
42
|
+
"""Run the full battery against one blob of bytes."""
|
|
43
|
+
findings: list[Finding] = []
|
|
44
|
+
|
|
45
|
+
def add(method: str, detail: str, raw: bytes):
|
|
46
|
+
text = _printable(raw)
|
|
47
|
+
if text is None:
|
|
48
|
+
return
|
|
49
|
+
score = analysis.english_score(text)
|
|
50
|
+
if score == float("-inf"):
|
|
51
|
+
return
|
|
52
|
+
findings.append(Finding(method, detail, text.strip()[:500], score))
|
|
53
|
+
|
|
54
|
+
# 1. layered encodings
|
|
55
|
+
for chain, decoded in encoding.smart_decode(data):
|
|
56
|
+
add("encoding", f"decoded via {chain}", decoded)
|
|
57
|
+
|
|
58
|
+
text = _printable(data)
|
|
59
|
+
|
|
60
|
+
# 2. hash id on standalone hex digests
|
|
61
|
+
if text:
|
|
62
|
+
for token in text.split():
|
|
63
|
+
names = hashid.identify(token.strip())
|
|
64
|
+
if names:
|
|
65
|
+
findings.append(Finding("hashid", f"{token.strip()[:64]} looks like {', '.join(names)}",
|
|
66
|
+
token.strip()[:200], 0.0))
|
|
67
|
+
|
|
68
|
+
# 3. single byte xor (worth trying on almost anything)
|
|
69
|
+
if len(data) >= 8:
|
|
70
|
+
for key, pt, score in xor.single_byte_xor_break(data, top=2):
|
|
71
|
+
if score > 5:
|
|
72
|
+
findings.append(Finding("xor", f"single byte key 0x{key:02x}", pt.decode("latin1").strip()[:500], score))
|
|
73
|
+
|
|
74
|
+
# 4. repeating key xor on longer blobs
|
|
75
|
+
if len(data) >= 60:
|
|
76
|
+
try:
|
|
77
|
+
for key, pt, score in xor.repeating_key_xor_break(data)[:2]:
|
|
78
|
+
if score > 20:
|
|
79
|
+
findings.append(Finding("xor", f"repeating key {key!r}", pt.decode("latin1").strip()[:500], score))
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# 5. classical ciphers on texty input
|
|
84
|
+
if text and sum(c.isalpha() for c in text) / max(len(text), 1) > 0.6 and len(text) >= 20:
|
|
85
|
+
for shift, pt, score in classical.caesar_bruteforce(text, top=1):
|
|
86
|
+
if score > 5:
|
|
87
|
+
findings.append(Finding("caesar", f"shift {shift}", pt.strip()[:500], score))
|
|
88
|
+
for a, b, pt, score in classical.affine_bruteforce(text, top=1):
|
|
89
|
+
if score > 10:
|
|
90
|
+
findings.append(Finding("affine", f"a={a} b={b}", pt.strip()[:500], score))
|
|
91
|
+
|
|
92
|
+
# 6. notes on randomness
|
|
93
|
+
entropy = analysis.shannon_entropy(data)
|
|
94
|
+
if entropy > 7.5 and len(data) >= 32:
|
|
95
|
+
findings.append(Finding("note", f"high entropy ({entropy:.2f} bits/byte), likely encrypted or compressed",
|
|
96
|
+
"", -100.0))
|
|
97
|
+
|
|
98
|
+
findings.sort(key=lambda f: -f.score)
|
|
99
|
+
return findings[:max_findings]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def solve_file(path: str) -> SolveReport:
|
|
103
|
+
with open(path, "rb") as f:
|
|
104
|
+
data = f.read()
|
|
105
|
+
return SolveReport(source=path, findings=analyze_blob(data))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_markdown(report: SolveReport) -> str:
|
|
109
|
+
lines = [f"# flaghunter solve report", "", f"source: `{report.source}`", ""]
|
|
110
|
+
if not report.findings:
|
|
111
|
+
lines.append("no promising findings. the blob resisted the battery.")
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
lines.append(f"## top findings ({len(report.findings)})")
|
|
114
|
+
lines.append("")
|
|
115
|
+
for i, f in enumerate(report.top(), 1):
|
|
116
|
+
lines.append(f"### {i}. {f.method} (score {f.score:.1f})")
|
|
117
|
+
lines.append(f"*{f.detail}*")
|
|
118
|
+
if f.plaintext:
|
|
119
|
+
lines.append("```")
|
|
120
|
+
lines.append(f.plaintext)
|
|
121
|
+
lines.append("```")
|
|
122
|
+
lines.append("")
|
|
123
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flaghunter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CTF crypto autosolver and pcap hunter. Point it at a challenge file or packet capture and it runs a full attack battery for you.
|
|
5
|
+
Author: Jake Burre
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/burrejak22/flaghunter
|
|
8
|
+
Project-URL: Repository, https://github.com/burrejak22/flaghunter
|
|
9
|
+
Keywords: ctf,crypto,pcap,forensics,security,codebreakers
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: cryptsmith>=0.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# flaghunter
|
|
22
|
+
|
|
23
|
+
Point it at a CTF crypto challenge or a packet capture and it runs a full attack battery for you, then writes up what it found.
|
|
24
|
+
|
|
25
|
+
Built on [cryptsmith](https://github.com/burrejak22/cryptsmith), which does all the crypto and pcap heavy lifting. flaghunter is the analyst on top.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install flaghunter
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# throw a crypto challenge file at it
|
|
37
|
+
flaghunter solve challenge.txt
|
|
38
|
+
|
|
39
|
+
# hunt a pcap for enrollment keys, flags, and obfuscated blobs
|
|
40
|
+
flaghunter pcap capture.pcap
|
|
41
|
+
|
|
42
|
+
# custom token pattern, json output
|
|
43
|
+
flaghunter pcap capture.pcap --pattern "FLAG\{[^}]+\}" --format json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What the pcap hunter does
|
|
47
|
+
|
|
48
|
+
1. Reassembles TCP streams from the capture
|
|
49
|
+
2. Carves printable strings out of every stream
|
|
50
|
+
3. Hunts tokens matching your pattern (default: 12 char alpha strings, like enrollment keys)
|
|
51
|
+
4. Scores every stream for entropy, then runs single byte xor, repeating key xor, caesar, and layered base64/hex decoding on anything suspicious
|
|
52
|
+
5. Writes a markdown report with stream labels, findings, and decoded candidates
|
|
53
|
+
|
|
54
|
+
## What the solver does
|
|
55
|
+
|
|
56
|
+
Reads a challenge file and runs the battery: encoding layers, xor attacks, classical ciphers, hash identification. Ranks everything by english score so the real answer floats to the top.
|
|
57
|
+
|
|
58
|
+
## Example
|
|
59
|
+
|
|
60
|
+
Say a capture has a server checking a 12 character alpha enrollment key, and the key is xor obfuscated in the traffic. flaghunter finds the stream, breaks the xor, and hands you the key.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/flaghunter/__init__.py
|
|
5
|
+
src/flaghunter/cli.py
|
|
6
|
+
src/flaghunter/pcaphunt.py
|
|
7
|
+
src/flaghunter/solver.py
|
|
8
|
+
src/flaghunter.egg-info/PKG-INFO
|
|
9
|
+
src/flaghunter.egg-info/SOURCES.txt
|
|
10
|
+
src/flaghunter.egg-info/dependency_links.txt
|
|
11
|
+
src/flaghunter.egg-info/entry_points.txt
|
|
12
|
+
src/flaghunter.egg-info/requires.txt
|
|
13
|
+
src/flaghunter.egg-info/top_level.txt
|
|
14
|
+
tests/test_flaghunter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptsmith>=0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flaghunter
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
import struct
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from cryptsmith import xor
|
|
7
|
+
|
|
8
|
+
from flaghunter import pcaphunt, solver
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write(path, data: bytes):
|
|
12
|
+
with open(path, "wb") as f:
|
|
13
|
+
f.write(data)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_pcap(path, packets):
|
|
17
|
+
def frame(src_ip, dst_ip, sport, dport, seq, payload):
|
|
18
|
+
eth = b"\x00" * 12 + struct.pack("!H", 0x0800)
|
|
19
|
+
total = 20 + 20 + len(payload)
|
|
20
|
+
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, total, 0, 0, 64, 6, 0,
|
|
21
|
+
bytes(map(int, src_ip.split("."))), bytes(map(int, dst_ip.split("."))))
|
|
22
|
+
tcp = struct.pack("!HHIIHHHH", sport, dport, seq, 0, 0x5000, 0, 0, 0)
|
|
23
|
+
return eth + ip + tcp + payload
|
|
24
|
+
|
|
25
|
+
with open(path, "wb") as f:
|
|
26
|
+
f.write(struct.pack("<IHHIIII", 0xA1B2C3D4, 2, 4, 0, 0, 65535, 1))
|
|
27
|
+
for (s, sp, d, dp, seq, payload) in packets:
|
|
28
|
+
p = frame(s, d, sp, dp, seq, payload)
|
|
29
|
+
f.write(struct.pack("<IIII", 0, 0, len(p), len(p)) + p)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_solver_breaks_single_byte_xor():
|
|
33
|
+
pt = b"the secret enrollment key is hidden right here in this message"
|
|
34
|
+
ct = xor.xor_bytes(pt, b"\x99")
|
|
35
|
+
with tempfile.NamedTemporaryFile(delete=False) as t:
|
|
36
|
+
_write(t.name, ct)
|
|
37
|
+
path = t.name
|
|
38
|
+
try:
|
|
39
|
+
report = solver.solve_file(path)
|
|
40
|
+
assert any(f.method == "xor" and "hidden right here" in f.plaintext
|
|
41
|
+
for f in report.findings)
|
|
42
|
+
finally:
|
|
43
|
+
os.unlink(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_solver_peels_base64_layers():
|
|
47
|
+
inner = base64.b64encode(b"flag{layered_encoding_win}").decode()
|
|
48
|
+
outer = base64.b64encode(inner.encode()).decode()
|
|
49
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as t:
|
|
50
|
+
_write(t.name, outer.encode())
|
|
51
|
+
path = t.name
|
|
52
|
+
try:
|
|
53
|
+
report = solver.solve_file(path)
|
|
54
|
+
assert any(f.method == "encoding" and "flag{layered_encoding_win}" in f.plaintext
|
|
55
|
+
for f in report.findings)
|
|
56
|
+
finally:
|
|
57
|
+
os.unlink(path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_pcap_hunt_finds_plaintext_enrollment_key():
|
|
61
|
+
with tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) as t:
|
|
62
|
+
path = t.name
|
|
63
|
+
try:
|
|
64
|
+
_build_pcap(path, [
|
|
65
|
+
("10.0.0.5", 1337, "10.0.0.9", 9000, 1, b"HELLO distsrv v1"),
|
|
66
|
+
("10.0.0.5", 1337, "10.0.0.9", 9000, 17, b"ENROLL ZxCvBnMkLjHg"),
|
|
67
|
+
("10.0.0.9", 9000, "10.0.0.5", 1337, 1, b"OK enrolled"),
|
|
68
|
+
])
|
|
69
|
+
report = pcaphunt.hunt_pcap(path)
|
|
70
|
+
assert report.streams == 1
|
|
71
|
+
tokens = [f.value for f in report.findings if f.kind == "token"]
|
|
72
|
+
assert "ZxCvBnMkLjHg" in tokens
|
|
73
|
+
finally:
|
|
74
|
+
os.unlink(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_pcap_hunt_breaks_xor_obfuscated_key():
|
|
78
|
+
key = b"QwErTyUiOpAs"
|
|
79
|
+
blob = xor.xor_bytes(b"enrollment key follows: " + key + b" end", b"\x42")
|
|
80
|
+
with tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) as t:
|
|
81
|
+
path = t.name
|
|
82
|
+
try:
|
|
83
|
+
_build_pcap(path, [
|
|
84
|
+
("10.0.0.5", 1337, "10.0.0.9", 9000, 1, b"\x01\x02hello"),
|
|
85
|
+
("10.0.0.9", 9000, "10.0.0.5", 1337, 1, blob),
|
|
86
|
+
])
|
|
87
|
+
report = pcaphunt.hunt_pcap(path)
|
|
88
|
+
values = [f.value for f in report.findings]
|
|
89
|
+
assert any("QwErTyUiOpAs" in v for v in values), values
|
|
90
|
+
kinds = [f.kind for f in report.findings if "QwErTyUiOpAs" in f.value]
|
|
91
|
+
assert "xor-token" in kinds
|
|
92
|
+
finally:
|
|
93
|
+
os.unlink(path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_pcap_hunt_custom_pattern():
|
|
97
|
+
with tempfile.NamedTemporaryFile(suffix=".pcap", delete=False) as t:
|
|
98
|
+
path = t.name
|
|
99
|
+
try:
|
|
100
|
+
_build_pcap(path, [
|
|
101
|
+
("10.0.0.5", 1337, "10.0.0.9", 9000, 1, b"here is FLAG{you_found_me} bye"),
|
|
102
|
+
])
|
|
103
|
+
report = pcaphunt.hunt_pcap(path, pattern=r"FLAG\{[^}]+\}")
|
|
104
|
+
assert any(f.value == "FLAG{you_found_me}" for f in report.findings)
|
|
105
|
+
finally:
|
|
106
|
+
os.unlink(path)
|