rcekit 2.35.0__tar.gz → 2.35.1__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.
- {rcekit-2.35.0 → rcekit-2.35.1}/CHANGELOG.md +41 -1
- {rcekit-2.35.0 → rcekit-2.35.1}/PKG-INFO +2 -2
- {rcekit-2.35.0 → rcekit-2.35.1}/README.md +1 -1
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/PKG-INFO +2 -2
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.py +152 -8
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_generator.py +212 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/CONTRIBUTING.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/LICENSE +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/MANIFEST.in +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/SECURITY.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/docs/generation.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/docs/guide.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/docs/reference.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/docs/verify-it-yourself.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/pyproject.toml +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/SOURCES.txt +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/dependency_links.txt +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/entry_points.txt +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/rcekit.egg-info/top_level.txt +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/setup.cfg +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/templates/payloads.json +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/README.md +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/cases/struts2-s2-001.json +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/cases/webmin-cve-2019-15107.json +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/requests/webmin.txt +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/bench/runner.py +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_bench_runner.py +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_corpus_embedding.py +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tests/test_docs.py +0 -0
- {rcekit-2.35.0 → rcekit-2.35.1}/tools/embed_corpus.py +0 -0
|
@@ -8,6 +8,45 @@ formats, or the template schema.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [2.35.1] — 2026-08-21
|
|
12
|
+
|
|
13
|
+
A robustness pass over error handling: no new capability, four ways the tool
|
|
14
|
+
could crash or mislead on input it did not choose.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **A truncated error response no longer ends the run.** Reading an
|
|
19
|
+
`HTTPError`'s body happens *inside* the `except` handler, where the sibling
|
|
20
|
+
`except Exception` cannot reach it — so a target that promised a
|
|
21
|
+
`Content-Length` it never delivered raised `ConnectionResetError` straight out
|
|
22
|
+
of `main()`, taking every probe already fired with it. The read is now
|
|
23
|
+
guarded: the status still comes back, an unreadable body is reported empty.
|
|
24
|
+
A body that *does* arrive is still returned in full — the 500-stack-trace
|
|
25
|
+
confirmations that branch exists for are unaffected.
|
|
26
|
+
- **A `--target-profile` is checked before it is used.** A profile is written by
|
|
27
|
+
hand, so a typo in one is ordinary; it surfaced as a traceback. A top level
|
|
28
|
+
that is not a JSON object, `deny_chars` that is not text, a `max_length` that
|
|
29
|
+
is not a number, a selector field that is not a list of names — each is now an
|
|
30
|
+
operator-readable `[!]` message and exit 1, the same way the sink-shape fields
|
|
31
|
+
already behaved. Twelve inputs that produced an `AttributeError` or a
|
|
32
|
+
`TypeError` now produce a sentence.
|
|
33
|
+
- **A selector field given as one string means one name.** `"environments":
|
|
34
|
+
"unix"` in a profile was iterated character by character, matched nothing, and
|
|
35
|
+
the empty run that followed was reported as a success. A string is now split
|
|
36
|
+
on commas: `"unix"` is `["unix"]`, `"raw, html"` is `["raw", "html"]`.
|
|
37
|
+
- **Unknown `--environments` and `--encodings` are named.** Both were silent, so
|
|
38
|
+
`--environments linux` — the corpus calls it `unix` — produced an empty file
|
|
39
|
+
and exit 0, indistinguishable from a target with no payloads for it. Both now
|
|
40
|
+
warn and list the names that exist, as unknown contexts and categories already
|
|
41
|
+
did. An empty result is also no longer announced as "Successfully generated 0
|
|
42
|
+
payloads"; it says the selection matched nothing and points at the filters.
|
|
43
|
+
- **A callback cannot rewrite the operator's terminal.** The host and path of an
|
|
44
|
+
OOB callback are chosen by the target. Printed raw, an ESC byte let that
|
|
45
|
+
target colour, erase and rewrite lines — hiding a genuine `[HIT]` behind
|
|
46
|
+
`\x1b[2K\r`, or forging one that never arrived. Control characters are now
|
|
47
|
+
escaped for display as `\xNN`; the recorded hit and the `--listen-log` JSONL
|
|
48
|
+
keep the bytes verbatim.
|
|
49
|
+
|
|
11
50
|
## [2.35.0] — 2026-08-20
|
|
12
51
|
|
|
13
52
|
### Added
|
|
@@ -977,7 +1016,8 @@ this file and have not been restated here.
|
|
|
977
1016
|
- **[2.7.0]**
|
|
978
1017
|
- **[2.1.0]**
|
|
979
1018
|
|
|
980
|
-
[Unreleased]: https://github.com/kabiri-labs/rcekit/compare/v2.35.
|
|
1019
|
+
[Unreleased]: https://github.com/kabiri-labs/rcekit/compare/v2.35.1...HEAD
|
|
1020
|
+
[2.35.1]: https://github.com/kabiri-labs/rcekit/compare/v2.35.0...v2.35.1
|
|
981
1021
|
[2.35.0]: https://github.com/kabiri-labs/rcekit/compare/v2.34.1...v2.35.0
|
|
982
1022
|
[2.34.1]: https://github.com/kabiri-labs/rcekit/compare/v2.34.0...v2.34.1
|
|
983
1023
|
[2.34.0]: https://github.com/kabiri-labs/rcekit/compare/v2.33.0...v2.34.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rcekit
|
|
3
|
-
Version: 2.35.
|
|
3
|
+
Version: 2.35.1
|
|
4
4
|
Summary: RCE detection & confirmation toolkit for authorised penetration testing
|
|
5
5
|
Author: Ahmad Kabiri
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,7 +24,7 @@ Dynamic: license-file
|
|
|
24
24
|
|
|
25
25
|
**`confirmed` means the target executed the input. `negative` means the probes reached it.**
|
|
26
26
|
|
|
27
|
-
**Version 2.35.
|
|
27
|
+
**Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
|
|
28
28
|
|
|
29
29
|
RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
|
|
30
30
|
testing, red teaming and security research. Point it at a target you are allowed
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**`confirmed` means the target executed the input. `negative` means the probes reached it.**
|
|
4
4
|
|
|
5
|
-
**Version 2.35.
|
|
5
|
+
**Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
|
|
6
6
|
|
|
7
7
|
RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
|
|
8
8
|
testing, red teaming and security research. Point it at a target you are allowed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rcekit
|
|
3
|
-
Version: 2.35.
|
|
3
|
+
Version: 2.35.1
|
|
4
4
|
Summary: RCE detection & confirmation toolkit for authorised penetration testing
|
|
5
5
|
Author: Ahmad Kabiri
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,7 +24,7 @@ Dynamic: license-file
|
|
|
24
24
|
|
|
25
25
|
**`confirmed` means the target executed the input. `negative` means the probes reached it.**
|
|
26
26
|
|
|
27
|
-
**Version 2.35.
|
|
27
|
+
**Version 2.35.1** · MIT · Python 3.8+ · zero third-party dependencies
|
|
28
28
|
|
|
29
29
|
RCEKit is an **RCE detection & confirmation toolkit** for authorised penetration
|
|
30
30
|
testing, red teaming and security research. Point it at a target you are allowed
|
|
@@ -45,7 +45,7 @@ def configure_logging() -> None:
|
|
|
45
45
|
|
|
46
46
|
# Bump on every change: PATCH for fixes, MINOR for new capabilities, MAJOR for
|
|
47
47
|
# breaking changes to the CLI, output formats, or template schema.
|
|
48
|
-
__version__ = "2.35.
|
|
48
|
+
__version__ = "2.35.1"
|
|
49
49
|
|
|
50
50
|
SAFETY_ORDER = {"safe": 0, "intrusive": 1, "stateful": 2}
|
|
51
51
|
|
|
@@ -1022,6 +1022,39 @@ class RCEKit:
|
|
|
1022
1022
|
return payload.replace("\r", " ").replace("\n", " ")
|
|
1023
1023
|
return payload
|
|
1024
1024
|
|
|
1025
|
+
def _known_environments(self, mode: str) -> Set[str]:
|
|
1026
|
+
"""Every environment name the corpus can actually yield in ``mode``."""
|
|
1027
|
+
if mode == "detection":
|
|
1028
|
+
return set(self.detection_payloads)
|
|
1029
|
+
known: Set[str] = set()
|
|
1030
|
+
for category in self.payload_categories.values():
|
|
1031
|
+
known.update(category)
|
|
1032
|
+
return known
|
|
1033
|
+
|
|
1034
|
+
def _warn_unknown_selectors(self, mode: str,
|
|
1035
|
+
selected_environments: Optional[List[str]],
|
|
1036
|
+
selected_encodings: Optional[List[str]]) -> None:
|
|
1037
|
+
"""Name any explicitly selected environment or encoding that matches
|
|
1038
|
+
nothing, once each.
|
|
1039
|
+
|
|
1040
|
+
A selector that matches nothing produces an empty run, and an empty run
|
|
1041
|
+
is reported as a success -- so ``--environments linux`` (the corpus
|
|
1042
|
+
calls it ``unix``) is indistinguishable from a target for which there
|
|
1043
|
+
are simply no payloads. Unknown contexts and categories were already
|
|
1044
|
+
named; these two went by in silence. Only *explicit* selections are
|
|
1045
|
+
checked: the defaults are the corpus's own and must not warn."""
|
|
1046
|
+
if selected_environments:
|
|
1047
|
+
known = self._known_environments(mode)
|
|
1048
|
+
for env in dict.fromkeys(selected_environments):
|
|
1049
|
+
if env not in known:
|
|
1050
|
+
logger.warning("Unknown environment: %s (known: %s)",
|
|
1051
|
+
env, ", ".join(sorted(known)))
|
|
1052
|
+
if selected_encodings:
|
|
1053
|
+
for enc in dict.fromkeys(selected_encodings):
|
|
1054
|
+
if enc not in self.encoding_methods:
|
|
1055
|
+
logger.warning("Unknown encoding: %s (known: %s)",
|
|
1056
|
+
enc, ", ".join(sorted(self.encoding_methods)))
|
|
1057
|
+
|
|
1025
1058
|
def generate_payload_records(
|
|
1026
1059
|
self,
|
|
1027
1060
|
selected_contexts: Optional[List[str]] = None,
|
|
@@ -1039,6 +1072,7 @@ class RCEKit:
|
|
|
1039
1072
|
encodings = selected_encodings if selected_encodings else (
|
|
1040
1073
|
self.safe_detection_encodings if mode == "detection" else list(self.default_encodings)
|
|
1041
1074
|
)
|
|
1075
|
+
self._warn_unknown_selectors(mode, selected_environments, selected_encodings)
|
|
1042
1076
|
|
|
1043
1077
|
if mode == "detection":
|
|
1044
1078
|
environments = selected_environments if selected_environments else list(self.detection_payloads.keys())
|
|
@@ -1497,7 +1531,18 @@ class RCEKit:
|
|
|
1497
1531
|
for entry in manifest:
|
|
1498
1532
|
map_file.write(json.dumps(entry, ensure_ascii=True) + "\n")
|
|
1499
1533
|
|
|
1500
|
-
|
|
1534
|
+
if count:
|
|
1535
|
+
logger.info("Successfully generated %s payloads to %s", count, output_path)
|
|
1536
|
+
else:
|
|
1537
|
+
# "Successfully generated 0 payloads" reads as a target with
|
|
1538
|
+
# nothing to fire at. Far more often it is a selector that
|
|
1539
|
+
# matched nothing, and the operator needs to hear the
|
|
1540
|
+
# difference before they conclude anything from the run.
|
|
1541
|
+
logger.warning(
|
|
1542
|
+
"No payloads matched the selection -- %s is empty. Check "
|
|
1543
|
+
"--contexts/--categories/--encodings/--environments and any "
|
|
1544
|
+
"--target-profile filters (--deny-chars, --max-length).",
|
|
1545
|
+
output_path)
|
|
1501
1546
|
if include_metadata and output_format == "text":
|
|
1502
1547
|
logger.info("Metadata sidecar written to %s", metadata_path)
|
|
1503
1548
|
if manifest:
|
|
@@ -2044,7 +2089,18 @@ class RCEKit:
|
|
|
2044
2089
|
return (response.status, text, self._channels_from_response(response, text, target),
|
|
2045
2090
|
time.time() - start)
|
|
2046
2091
|
except urllib.error.HTTPError as exc:
|
|
2047
|
-
|
|
2092
|
+
# Reading the error body can itself fail: a target that truncates
|
|
2093
|
+
# its own error response -- a Content-Length it never delivers,
|
|
2094
|
+
# then a reset -- raises here, mid-`except`. An exception raised
|
|
2095
|
+
# inside an except block is NOT caught by the sibling handler
|
|
2096
|
+
# below, so without this guard one malformed error response ends
|
|
2097
|
+
# the whole run and every probe fired so far is lost. The status
|
|
2098
|
+
# is what the caller needs most; an unreadable body is a body we
|
|
2099
|
+
# report as empty, exactly as a delivery failure would.
|
|
2100
|
+
try:
|
|
2101
|
+
text = exc.read().decode(errors="replace")
|
|
2102
|
+
except Exception:
|
|
2103
|
+
text = ""
|
|
2048
2104
|
return (exc.code, text, self._channels_from_response(exc, text, target),
|
|
2049
2105
|
time.time() - start)
|
|
2050
2106
|
except Exception as exc: # network error, timeout, etc.
|
|
@@ -2892,6 +2948,31 @@ class RCEKit:
|
|
|
2892
2948
|
except Exception as exc:
|
|
2893
2949
|
logger.error("Unable to log exploitation usage: %s", exc)
|
|
2894
2950
|
|
|
2951
|
+
|
|
2952
|
+
def terminal_safe(text: str) -> str:
|
|
2953
|
+
"""Escape control characters for display on the operator's terminal.
|
|
2954
|
+
|
|
2955
|
+
A callback's host and path are chosen by the *target*, which in an
|
|
2956
|
+
engagement is hostile by definition. Printed raw, an ESC byte lets that
|
|
2957
|
+
target colour, erase and rewrite lines in the operator's own terminal --
|
|
2958
|
+
hiding a genuine hit behind ``\\x1b[2K\\r``, or forging one that never
|
|
2959
|
+
arrived. For a tool whose whole claim is that its output can be trusted,
|
|
2960
|
+
that is the output being forged.
|
|
2961
|
+
|
|
2962
|
+
Escaped rather than stripped: what arrived is still visible, as ``\\xNN``,
|
|
2963
|
+
so the operator can see that something tried this. Only the display is
|
|
2964
|
+
changed -- the recorded hit and the JSONL log keep the bytes verbatim."""
|
|
2965
|
+
out: List[str] = []
|
|
2966
|
+
for char in text:
|
|
2967
|
+
if char.isprintable():
|
|
2968
|
+
out.append(char)
|
|
2969
|
+
elif ord(char) <= 0xFF:
|
|
2970
|
+
out.append("\\x%02x" % ord(char))
|
|
2971
|
+
else:
|
|
2972
|
+
out.append("\\u%04x" % ord(char))
|
|
2973
|
+
return "".join(out)
|
|
2974
|
+
|
|
2975
|
+
|
|
2895
2976
|
class OOBListener:
|
|
2896
2977
|
"""A lightweight out-of-band listener that records DNS/HTTP callbacks and
|
|
2897
2978
|
correlates each one back to the payload whose unique token produced it.
|
|
@@ -2952,12 +3033,19 @@ class OOBListener:
|
|
|
2952
3033
|
"context": entry.get("context") if entry else None,
|
|
2953
3034
|
}
|
|
2954
3035
|
self.hits.append(hit)
|
|
2955
|
-
|
|
3036
|
+
# Everything interpolated below reached us over the wire (or, for the
|
|
3037
|
+
# manifest fields, out of a file the operator may have hand-edited),
|
|
3038
|
+
# so none of it goes to the terminal unescaped. The hit recorded above
|
|
3039
|
+
# keeps the raw bytes.
|
|
3040
|
+
where = terminal_safe(f"{host or path}")
|
|
3041
|
+
safe_token = terminal_safe(token) if token else token
|
|
2956
3042
|
if entry:
|
|
2957
|
-
print(f"[HIT] {proto} token={
|
|
2958
|
-
f"
|
|
3043
|
+
print(f"[HIT] {proto} token={safe_token} from {source} "
|
|
3044
|
+
f"-> {terminal_safe(str(entry.get('payload')))} "
|
|
3045
|
+
f"[{terminal_safe(str(entry.get('category')))}/"
|
|
3046
|
+
f"{terminal_safe(str(entry.get('context')))}]")
|
|
2959
3047
|
else:
|
|
2960
|
-
print(f"[HIT] {proto} from {source} -> {where} (token={
|
|
3048
|
+
print(f"[HIT] {proto} from {source} -> {where} (token={safe_token}; not in manifest)")
|
|
2961
3049
|
if self.log_path:
|
|
2962
3050
|
try:
|
|
2963
3051
|
with open(self.log_path, "a", encoding="utf-8") as log_file:
|
|
@@ -5576,6 +5664,62 @@ def build_request_inputs(text: str, param: Optional[str] = None,
|
|
|
5576
5664
|
return url, method, data, out_headers, injection
|
|
5577
5665
|
|
|
5578
5666
|
|
|
5667
|
+
# Selector fields a profile may give as a list of names or as one
|
|
5668
|
+
# comma-separated string. Normalised to a list, because a bare string handed
|
|
5669
|
+
# to the generator would be iterated *character by character* -- "unix" would
|
|
5670
|
+
# select nothing at all, silently.
|
|
5671
|
+
_PROFILE_LIST_FIELDS = ("environments", "contexts", "categories", "encodings",
|
|
5672
|
+
"sink_decodes")
|
|
5673
|
+
# Fields whose string form main() already knows how to split itself.
|
|
5674
|
+
_PROFILE_STRING_OR_LIST_FIELDS = ("separators", "eval_engines")
|
|
5675
|
+
|
|
5676
|
+
|
|
5677
|
+
def validate_target_profile(profile: Any) -> Dict[str, Any]:
|
|
5678
|
+
"""Check a loaded target profile's shape and normalise its selector fields,
|
|
5679
|
+
raising ``ValueError`` with an operator-readable message.
|
|
5680
|
+
|
|
5681
|
+
A profile is a file someone writes by hand, so the failure to design for is
|
|
5682
|
+
a typo: a number where a character set belongs, a bare string where a list
|
|
5683
|
+
does. Left unchecked those surface far from their cause -- as a ``TypeError``
|
|
5684
|
+
deep in the payload filter, or as a run that quietly generates nothing.
|
|
5685
|
+
Every check here guards a field the caller goes on to join, index, or
|
|
5686
|
+
compare."""
|
|
5687
|
+
if not isinstance(profile, dict):
|
|
5688
|
+
raise ValueError(f"a target profile must be a JSON object, not "
|
|
5689
|
+
f"{type(profile).__name__}.")
|
|
5690
|
+
|
|
5691
|
+
def _names(field: str, value: Any) -> List[str]:
|
|
5692
|
+
if isinstance(value, str):
|
|
5693
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
5694
|
+
if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
|
|
5695
|
+
return list(value)
|
|
5696
|
+
raise ValueError(f"'{field}' must be a string or a list of strings, "
|
|
5697
|
+
f"not {type(value).__name__}.")
|
|
5698
|
+
|
|
5699
|
+
for field in _PROFILE_LIST_FIELDS:
|
|
5700
|
+
if profile.get(field) is not None:
|
|
5701
|
+
profile[field] = _names(field, profile[field])
|
|
5702
|
+
for field in _PROFILE_STRING_OR_LIST_FIELDS:
|
|
5703
|
+
if profile.get(field) is not None and not isinstance(profile[field], str):
|
|
5704
|
+
_names(field, profile[field])
|
|
5705
|
+
|
|
5706
|
+
deny = profile.get("deny_chars")
|
|
5707
|
+
# A string is already a set of characters; a list is joined into one.
|
|
5708
|
+
if deny is not None and not isinstance(deny, str):
|
|
5709
|
+
_names("deny_chars", deny)
|
|
5710
|
+
|
|
5711
|
+
length = profile.get("max_length")
|
|
5712
|
+
if length is not None and (isinstance(length, bool) or not isinstance(length, int)):
|
|
5713
|
+
raise ValueError(f"'max_length' must be a whole number, not "
|
|
5714
|
+
f"{type(length).__name__}.")
|
|
5715
|
+
|
|
5716
|
+
request = profile.get("request")
|
|
5717
|
+
if request is not None and not isinstance(request, dict):
|
|
5718
|
+
raise ValueError(f"'request' must be a JSON object, not "
|
|
5719
|
+
f"{type(request).__name__}.")
|
|
5720
|
+
return profile
|
|
5721
|
+
|
|
5722
|
+
|
|
5579
5723
|
def load_token_manifest(path: str) -> Dict[str, Dict[str, Any]]:
|
|
5580
5724
|
"""Load a .map.jsonl manifest into a token -> entry dict."""
|
|
5581
5725
|
tokens: Dict[str, Dict[str, Any]] = {}
|
|
@@ -5913,7 +6057,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
5913
6057
|
if args.target_profile:
|
|
5914
6058
|
try:
|
|
5915
6059
|
with open(args.target_profile, "r", encoding="utf-8") as profile_file:
|
|
5916
|
-
profile = json.load(profile_file)
|
|
6060
|
+
profile = validate_target_profile(json.load(profile_file))
|
|
5917
6061
|
except Exception as exc:
|
|
5918
6062
|
print(f"[!] Unable to load target profile {args.target_profile}: {exc}")
|
|
5919
6063
|
return 1
|
|
@@ -6256,3 +6256,215 @@ class DeserializationSinkTestCase(unittest.TestCase):
|
|
|
6256
6256
|
|
|
6257
6257
|
if __name__ == "__main__":
|
|
6258
6258
|
unittest.main()
|
|
6259
|
+
|
|
6260
|
+
|
|
6261
|
+
class TruncatedErrorResponseTestCase(unittest.TestCase):
|
|
6262
|
+
"""A target may cut its own error response short. Reading that body raises
|
|
6263
|
+
*inside* the ``except HTTPError`` handler, where the sibling ``except
|
|
6264
|
+
Exception`` cannot catch it -- so before the guard, one malformed error
|
|
6265
|
+
response ended the whole run and lost every probe already fired."""
|
|
6266
|
+
|
|
6267
|
+
def _serve(self, handler):
|
|
6268
|
+
import socket
|
|
6269
|
+
import threading
|
|
6270
|
+
server = socket.socket()
|
|
6271
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
6272
|
+
server.bind(("127.0.0.1", 0))
|
|
6273
|
+
server.listen(8)
|
|
6274
|
+
port = server.getsockname()[1]
|
|
6275
|
+
|
|
6276
|
+
def loop():
|
|
6277
|
+
while True:
|
|
6278
|
+
try:
|
|
6279
|
+
conn, _ = server.accept()
|
|
6280
|
+
except OSError:
|
|
6281
|
+
return
|
|
6282
|
+
threading.Thread(target=handler, args=(conn,), daemon=True).start()
|
|
6283
|
+
|
|
6284
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
6285
|
+
self.addCleanup(server.close)
|
|
6286
|
+
return port
|
|
6287
|
+
|
|
6288
|
+
def test_error_body_that_never_arrives_does_not_end_the_run(self):
|
|
6289
|
+
def truncate(conn):
|
|
6290
|
+
# Promise 4000 bytes, send 10, then reset the connection.
|
|
6291
|
+
conn.sendall(b"HTTP/1.1 500 Internal Server Error\r\n"
|
|
6292
|
+
b"Content-Length: 4000\r\n\r\n" + b"A" * 10)
|
|
6293
|
+
conn.close()
|
|
6294
|
+
|
|
6295
|
+
port = self._serve(truncate)
|
|
6296
|
+
generator = RCEKit()
|
|
6297
|
+
status, body, channels, elapsed = generator._fire_channels(
|
|
6298
|
+
"probe", f"http://127.0.0.1:{port}/x?cmd=FUZZ", "GET", None, None,
|
|
6299
|
+
"query_value", "json_string", 5)
|
|
6300
|
+
# The status is what the caller needs most; an unreadable body is
|
|
6301
|
+
# reported as empty rather than as a traceback.
|
|
6302
|
+
self.assertIn(status, (500, None))
|
|
6303
|
+
self.assertIsInstance(body, str)
|
|
6304
|
+
self.assertIsInstance(channels, list)
|
|
6305
|
+
self.assertGreaterEqual(elapsed, 0.0)
|
|
6306
|
+
|
|
6307
|
+
def test_readable_error_body_is_still_returned(self):
|
|
6308
|
+
"""The guard must not cost the 500-stack-trace confirmations it exists
|
|
6309
|
+
alongside: a complete error body still comes back in full."""
|
|
6310
|
+
def complete(conn):
|
|
6311
|
+
body = b"computed 1355862 here"
|
|
6312
|
+
conn.sendall(b"HTTP/1.1 500 Internal Server Error\r\n"
|
|
6313
|
+
b"Content-Length: %d\r\n\r\n" % len(body) + body)
|
|
6314
|
+
conn.close()
|
|
6315
|
+
|
|
6316
|
+
port = self._serve(complete)
|
|
6317
|
+
generator = RCEKit()
|
|
6318
|
+
status, body, _, _ = generator._fire_channels(
|
|
6319
|
+
"probe", f"http://127.0.0.1:{port}/x?cmd=FUZZ", "GET", None, None,
|
|
6320
|
+
"query_value", "json_string", 5)
|
|
6321
|
+
self.assertEqual(status, 500)
|
|
6322
|
+
self.assertIn("1355862", body)
|
|
6323
|
+
|
|
6324
|
+
|
|
6325
|
+
class TargetProfileValidationTestCase(unittest.TestCase):
|
|
6326
|
+
"""A profile is hand-edited, so a typo in one is ordinary. It must produce
|
|
6327
|
+
an operator-readable message, not a traceback thousands of payloads later."""
|
|
6328
|
+
|
|
6329
|
+
def test_top_level_must_be_an_object(self):
|
|
6330
|
+
for value in ([], None, "hello", 42, True):
|
|
6331
|
+
with self.subTest(value=value):
|
|
6332
|
+
with self.assertRaises(ValueError) as caught:
|
|
6333
|
+
rcekit.validate_target_profile(value)
|
|
6334
|
+
self.assertIn("must be a JSON object", str(caught.exception))
|
|
6335
|
+
|
|
6336
|
+
def test_selector_fields_reject_non_string_values(self):
|
|
6337
|
+
for field in ("environments", "contexts", "categories", "encodings",
|
|
6338
|
+
"sink_decodes"):
|
|
6339
|
+
with self.subTest(field=field):
|
|
6340
|
+
with self.assertRaises(ValueError) as caught:
|
|
6341
|
+
rcekit.validate_target_profile({field: 5})
|
|
6342
|
+
self.assertIn(field, str(caught.exception))
|
|
6343
|
+
|
|
6344
|
+
def test_selector_string_is_one_name_not_one_character_each(self):
|
|
6345
|
+
"""``"unix"`` iterated as characters selected nothing at all, and the
|
|
6346
|
+
empty run that followed was reported as a success."""
|
|
6347
|
+
profile = rcekit.validate_target_profile({"environments": "unix"})
|
|
6348
|
+
self.assertEqual(profile["environments"], ["unix"])
|
|
6349
|
+
profile = rcekit.validate_target_profile({"contexts": "raw, html"})
|
|
6350
|
+
self.assertEqual(profile["contexts"], ["raw", "html"])
|
|
6351
|
+
|
|
6352
|
+
def test_deny_chars_accepts_a_string_or_a_list(self):
|
|
6353
|
+
self.assertEqual(
|
|
6354
|
+
rcekit.validate_target_profile({"deny_chars": ";|&"})["deny_chars"], ";|&")
|
|
6355
|
+
self.assertEqual(
|
|
6356
|
+
rcekit.validate_target_profile({"deny_chars": [";", "|"]})["deny_chars"],
|
|
6357
|
+
[";", "|"])
|
|
6358
|
+
for bad in (123, True, {"a": 1}):
|
|
6359
|
+
with self.subTest(bad=bad):
|
|
6360
|
+
with self.assertRaises(ValueError):
|
|
6361
|
+
rcekit.validate_target_profile({"deny_chars": bad})
|
|
6362
|
+
|
|
6363
|
+
def test_max_length_must_be_a_whole_number(self):
|
|
6364
|
+
self.assertEqual(
|
|
6365
|
+
rcekit.validate_target_profile({"max_length": 120})["max_length"], 120)
|
|
6366
|
+
for bad in ("abc", [1], {}, True):
|
|
6367
|
+
with self.subTest(bad=bad):
|
|
6368
|
+
with self.assertRaises(ValueError) as caught:
|
|
6369
|
+
rcekit.validate_target_profile({"max_length": bad})
|
|
6370
|
+
self.assertIn("max_length", str(caught.exception))
|
|
6371
|
+
|
|
6372
|
+
def test_request_must_be_an_object(self):
|
|
6373
|
+
with self.assertRaises(ValueError) as caught:
|
|
6374
|
+
rcekit.validate_target_profile({"request": 5})
|
|
6375
|
+
self.assertIn("request", str(caught.exception))
|
|
6376
|
+
|
|
6377
|
+
def test_shipped_profiles_all_validate(self):
|
|
6378
|
+
shipped = sorted((Path(__file__).resolve().parent.parent / "profiles").glob("*.json"))
|
|
6379
|
+
self.assertTrue(shipped, "no profiles shipped to validate")
|
|
6380
|
+
for path in shipped:
|
|
6381
|
+
with self.subTest(profile=path.name):
|
|
6382
|
+
with path.open(encoding="utf-8") as handle:
|
|
6383
|
+
rcekit.validate_target_profile(json.load(handle))
|
|
6384
|
+
|
|
6385
|
+
|
|
6386
|
+
class UnknownSelectorWarningTestCase(unittest.TestCase):
|
|
6387
|
+
"""A selector matching nothing yields an empty run, and an empty run reads
|
|
6388
|
+
as 'this target has no payloads'. Unknown names have to be named."""
|
|
6389
|
+
|
|
6390
|
+
def test_unknown_environment_is_named(self):
|
|
6391
|
+
generator = RCEKit()
|
|
6392
|
+
with self.assertLogs("rcekit", level="WARNING") as logs:
|
|
6393
|
+
list(generator.generate_payload_records(selected_environments=["linux"]))
|
|
6394
|
+
joined = "\n".join(logs.output)
|
|
6395
|
+
self.assertIn("Unknown environment: linux", joined)
|
|
6396
|
+
# The message has to carry the answer, not just the complaint.
|
|
6397
|
+
self.assertIn("unix", joined)
|
|
6398
|
+
|
|
6399
|
+
def test_unknown_encoding_is_named(self):
|
|
6400
|
+
generator = RCEKit()
|
|
6401
|
+
with self.assertLogs("rcekit", level="WARNING") as logs:
|
|
6402
|
+
list(generator.generate_payload_records(selected_encodings=["nosuchenc"]))
|
|
6403
|
+
self.assertIn("Unknown encoding: nosuchenc", "\n".join(logs.output))
|
|
6404
|
+
|
|
6405
|
+
def test_known_selectors_stay_quiet(self):
|
|
6406
|
+
generator = RCEKit()
|
|
6407
|
+
with self.assertLogs("rcekit", level="WARNING") as logs:
|
|
6408
|
+
logger_under_test = rcekit.logger
|
|
6409
|
+
logger_under_test.warning("sentinel")
|
|
6410
|
+
list(generator.generate_payload_records(
|
|
6411
|
+
selected_environments=["unix"], selected_encodings=["url_encode"]))
|
|
6412
|
+
self.assertEqual([line for line in logs.output if "Unknown" in line], [])
|
|
6413
|
+
|
|
6414
|
+
def test_defaults_never_warn(self):
|
|
6415
|
+
"""The default lists are the corpus's own; warning on them would make
|
|
6416
|
+
every ordinary run noisy."""
|
|
6417
|
+
generator = RCEKit()
|
|
6418
|
+
with self.assertLogs("rcekit", level="WARNING") as logs:
|
|
6419
|
+
rcekit.logger.warning("sentinel")
|
|
6420
|
+
list(generator.generate_payload_records(max_safety="safe"))
|
|
6421
|
+
self.assertEqual(
|
|
6422
|
+
[line for line in logs.output if "Unknown environment" in line], [])
|
|
6423
|
+
|
|
6424
|
+
|
|
6425
|
+
class TerminalSafeOutputTestCase(unittest.TestCase):
|
|
6426
|
+
"""A callback's host and path are chosen by the target. Printed raw, an ESC
|
|
6427
|
+
byte lets that target rewrite the operator's terminal -- hiding a real hit
|
|
6428
|
+
or forging one."""
|
|
6429
|
+
|
|
6430
|
+
def test_control_bytes_are_escaped(self):
|
|
6431
|
+
self.assertEqual(rcekit.terminal_safe("a\x1b[31mb"), "a\\x1b[31mb")
|
|
6432
|
+
self.assertEqual(rcekit.terminal_safe("x\ry\nz"), "x\\x0dy\\x0az")
|
|
6433
|
+
self.assertEqual(rcekit.terminal_safe("nul\x00here"), "nul\\x00here")
|
|
6434
|
+
|
|
6435
|
+
def test_printable_text_is_untouched(self):
|
|
6436
|
+
for text in ("plain.host.example", "/path?q=1", "; curl http://x/", "héllo"):
|
|
6437
|
+
with self.subTest(text=text):
|
|
6438
|
+
self.assertEqual(rcekit.terminal_safe(text), text)
|
|
6439
|
+
|
|
6440
|
+
def test_non_bmp_and_separator_codepoints_are_escaped(self):
|
|
6441
|
+
# U+2028 LINE SEPARATOR is non-printable but above 0xFF.
|
|
6442
|
+
self.assertEqual(rcekit.terminal_safe("a
b"), "a\\u2028b")
|
|
6443
|
+
|
|
6444
|
+
def test_listener_never_prints_a_raw_escape(self):
|
|
6445
|
+
import contextlib
|
|
6446
|
+
import io
|
|
6447
|
+
listener = OOBListener()
|
|
6448
|
+
hostile = "evil\x1b[31mRED\x1b[0m\x1b[2K\rspoofed"
|
|
6449
|
+
buffer = io.StringIO()
|
|
6450
|
+
with contextlib.redirect_stdout(buffer):
|
|
6451
|
+
hit = listener.record("http", "10.0.0.5", hostile, "/cb")
|
|
6452
|
+
printed = buffer.getvalue()
|
|
6453
|
+
self.assertNotIn("\x1b", printed)
|
|
6454
|
+
self.assertNotIn("\r", printed)
|
|
6455
|
+
self.assertIn("\\x1b", printed)
|
|
6456
|
+
# The record itself stays faithful -- only the display is escaped.
|
|
6457
|
+
self.assertEqual(hit["host"], hostile)
|
|
6458
|
+
|
|
6459
|
+
def test_manifest_payload_is_escaped_on_the_hit_line(self):
|
|
6460
|
+
tokens = {"tok1": {"payload": "; curl http://tok1.oob.test/\r\n",
|
|
6461
|
+
"category": "oob", "context": "raw"}}
|
|
6462
|
+
listener = OOBListener(tokens=tokens)
|
|
6463
|
+
import contextlib
|
|
6464
|
+
import io
|
|
6465
|
+
buffer = io.StringIO()
|
|
6466
|
+
with contextlib.redirect_stdout(buffer):
|
|
6467
|
+
listener.record("dns", "10.0.0.5", "tok1.oob.test", "")
|
|
6468
|
+
printed = buffer.getvalue()
|
|
6469
|
+
self.assertNotIn("\r", printed)
|
|
6470
|
+
self.assertIn("\\x0d", printed)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|