ptpasstime 0.0.1__py3-none-any.whl
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.
- ptpasstime/__init__.py +1 -0
- ptpasstime/_version.py +1 -0
- ptpasstime/ptpasstime.py +647 -0
- ptpasstime-0.0.1.dist-info/METADATA +144 -0
- ptpasstime-0.0.1.dist-info/RECORD +9 -0
- ptpasstime-0.0.1.dist-info/WHEEL +5 -0
- ptpasstime-0.0.1.dist-info/entry_points.txt +2 -0
- ptpasstime-0.0.1.dist-info/licenses/LICENSE +674 -0
- ptpasstime-0.0.1.dist-info/top_level.txt +1 -0
ptpasstime/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
ptpasstime/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
ptpasstime/ptpasstime.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
Copyright (c) 2026 Penterep Security s.r.o.
|
|
4
|
+
|
|
5
|
+
ptpasstime - password comparison timing attack tester
|
|
6
|
+
|
|
7
|
+
ptpasstime is free software: you can redistribute it and/or modify
|
|
8
|
+
it under the terms of the GNU General Public License as published by
|
|
9
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
(at your option) any later version.
|
|
11
|
+
|
|
12
|
+
ptpasstime is distributed in the hope that it will be useful,
|
|
13
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
GNU General Public License for more details.
|
|
16
|
+
|
|
17
|
+
You should have received a copy of the GNU General Public License
|
|
18
|
+
along with ptpasstime. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import base64
|
|
25
|
+
import binascii
|
|
26
|
+
import statistics
|
|
27
|
+
import string
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import parse_qsl, urlparse, urlunparse
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
from scipy.stats import mannwhitneyu
|
|
36
|
+
|
|
37
|
+
sys.path.append(__file__.rsplit("/", 1)[0])
|
|
38
|
+
|
|
39
|
+
from _version import __version__
|
|
40
|
+
from ptlibs import ptjsonlib, ptprinthelper, ptmisclib, ptnethelper
|
|
41
|
+
from ptlibs.ptprinthelper import ptprint
|
|
42
|
+
|
|
43
|
+
DEFAULT_PLACEHOLDER = "INJECT"
|
|
44
|
+
DEFAULT_WRONG_CHAR = "X"
|
|
45
|
+
DEFAULT_CHARSET = string.ascii_lowercase + string.digits + string.ascii_uppercase
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RequestSpec:
|
|
50
|
+
method: str
|
|
51
|
+
url: str
|
|
52
|
+
headers: dict[str, str]
|
|
53
|
+
body_template: str | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AttemptResult:
|
|
58
|
+
attempt_type: str
|
|
59
|
+
password: str
|
|
60
|
+
median_ms: float
|
|
61
|
+
samples_ms: list[float]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PtPassTime:
|
|
65
|
+
def __init__(self, args: argparse.Namespace):
|
|
66
|
+
self.args = args
|
|
67
|
+
self.ptjsonlib = ptjsonlib.PtJsonLib()
|
|
68
|
+
self.spec = self._build_request_spec()
|
|
69
|
+
|
|
70
|
+
def run(self) -> None:
|
|
71
|
+
if self.args.brute_force:
|
|
72
|
+
self._run_brute_force()
|
|
73
|
+
else:
|
|
74
|
+
self._run_detection()
|
|
75
|
+
|
|
76
|
+
def _build_request_spec(self) -> RequestSpec:
|
|
77
|
+
if self.args.request_file:
|
|
78
|
+
return self._spec_from_request_file(self.args.request_file)
|
|
79
|
+
|
|
80
|
+
if not self.args.url:
|
|
81
|
+
raise ValueError("URL is required when --request-file is not used.")
|
|
82
|
+
if self.args.data is None:
|
|
83
|
+
raise ValueError("POST data (-d) is required when --request-file is not used.")
|
|
84
|
+
|
|
85
|
+
return RequestSpec(
|
|
86
|
+
method="POST",
|
|
87
|
+
url=self.args.url,
|
|
88
|
+
headers=self.args.headers.copy(),
|
|
89
|
+
body_template=self.args.data,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _spec_from_request_file(self, request_file_or_b64: str) -> RequestSpec:
|
|
93
|
+
raw = self._load_request_source(request_file_or_b64)
|
|
94
|
+
text = raw.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
95
|
+
lines = text.split("\n")
|
|
96
|
+
if not lines or not lines[0].strip():
|
|
97
|
+
raise ValueError("Invalid request input: missing request line.")
|
|
98
|
+
|
|
99
|
+
first = lines[0].strip()
|
|
100
|
+
parts = first.split()
|
|
101
|
+
if len(parts) < 2:
|
|
102
|
+
raise ValueError("Invalid request line, expected: METHOD PATH HTTP/x.y")
|
|
103
|
+
|
|
104
|
+
method = parts[0].upper()
|
|
105
|
+
target = parts[1]
|
|
106
|
+
headers: dict[str, str] = {}
|
|
107
|
+
body_idx = len(lines)
|
|
108
|
+
for i in range(1, len(lines)):
|
|
109
|
+
line = lines[i]
|
|
110
|
+
if line == "":
|
|
111
|
+
body_idx = i + 1
|
|
112
|
+
break
|
|
113
|
+
if ":" not in line:
|
|
114
|
+
continue
|
|
115
|
+
key, value = line.split(":", 1)
|
|
116
|
+
headers[key.strip()] = value.strip()
|
|
117
|
+
|
|
118
|
+
body_text = "\n".join(lines[body_idx:]) if body_idx < len(lines) else ""
|
|
119
|
+
url = self._build_url_from_target(target, headers)
|
|
120
|
+
merged_headers = self.args.headers.copy()
|
|
121
|
+
merged_headers.update(headers)
|
|
122
|
+
|
|
123
|
+
return RequestSpec(
|
|
124
|
+
method=method,
|
|
125
|
+
url=url,
|
|
126
|
+
headers=merged_headers,
|
|
127
|
+
body_template=body_text if body_text else None,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _load_request_source(self, value: str) -> bytes:
|
|
131
|
+
path = Path(value)
|
|
132
|
+
if path.is_file():
|
|
133
|
+
return path.read_bytes()
|
|
134
|
+
try:
|
|
135
|
+
return base64.b64decode(value, validate=True)
|
|
136
|
+
except (binascii.Error, ValueError) as exc:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"Invalid --request-file value. Use an existing file path or base64 encoded request."
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
def _build_url_from_target(self, target: str, headers: dict[str, str]) -> str:
|
|
142
|
+
if target.startswith(("http://", "https://")):
|
|
143
|
+
return target
|
|
144
|
+
|
|
145
|
+
host = headers.get("Host", "").strip()
|
|
146
|
+
if not host:
|
|
147
|
+
raise ValueError("Raw request contains relative path but missing Host header.")
|
|
148
|
+
base = self.args.url if self.args.url else f"http://{host}"
|
|
149
|
+
parsed = urlparse(base if "://" in base else f"http://{base}")
|
|
150
|
+
path = target if target.startswith("/") else f"/{target}"
|
|
151
|
+
return urlunparse((parsed.scheme, host, path, "", "", ""))
|
|
152
|
+
|
|
153
|
+
def _build_body(self, password: str) -> str | bytes | dict[str, str] | None:
|
|
154
|
+
if self.spec.body_template is None:
|
|
155
|
+
return None
|
|
156
|
+
if self.args.placeholder not in self.spec.body_template:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Placeholder '{self.args.placeholder}' not found in request body. "
|
|
159
|
+
"Use -d 'username=admin&password=INJECT' or include INJECT in --request-file."
|
|
160
|
+
)
|
|
161
|
+
body = self.spec.body_template.replace(self.args.placeholder, password)
|
|
162
|
+
content_type = self.spec.headers.get("Content-Type", "").lower()
|
|
163
|
+
if "application/x-www-form-urlencoded" in content_type or (
|
|
164
|
+
not content_type and "=" in body and not body.strip().startswith("{")
|
|
165
|
+
):
|
|
166
|
+
return dict(parse_qsl(body, keep_blank_values=True))
|
|
167
|
+
return body.encode("utf-8")
|
|
168
|
+
|
|
169
|
+
def _send_timed_request(self, password: str) -> float:
|
|
170
|
+
start = time.perf_counter()
|
|
171
|
+
requests.request(
|
|
172
|
+
method=self.spec.method,
|
|
173
|
+
url=self.spec.url,
|
|
174
|
+
headers=self.spec.headers,
|
|
175
|
+
data=self._build_body(password),
|
|
176
|
+
timeout=self.args.timeout,
|
|
177
|
+
proxies=self.args.proxy,
|
|
178
|
+
allow_redirects=self.args.redirects,
|
|
179
|
+
verify=False,
|
|
180
|
+
)
|
|
181
|
+
return (time.perf_counter() - start) * 1000
|
|
182
|
+
|
|
183
|
+
def _measure_password(self, attempt_type: str, password: str, repeat: int | None = None) -> AttemptResult:
|
|
184
|
+
n = repeat if repeat is not None else self.args.repeat
|
|
185
|
+
samples: list[float] = []
|
|
186
|
+
errors = 0
|
|
187
|
+
for _ in range(n):
|
|
188
|
+
try:
|
|
189
|
+
samples.append(self._send_timed_request(password))
|
|
190
|
+
except requests.RequestException:
|
|
191
|
+
errors += 1
|
|
192
|
+
|
|
193
|
+
if not samples:
|
|
194
|
+
raise RuntimeError(f"All {n} attempts failed for type '{attempt_type}'.")
|
|
195
|
+
|
|
196
|
+
if errors and not self.args.json:
|
|
197
|
+
ptprint(
|
|
198
|
+
f"{attempt_type}: {errors}/{n} requests failed",
|
|
199
|
+
"WARNING",
|
|
200
|
+
condition=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return AttemptResult(
|
|
204
|
+
attempt_type=attempt_type,
|
|
205
|
+
password=password,
|
|
206
|
+
median_ms=statistics.median(samples),
|
|
207
|
+
samples_ms=samples,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _is_server_vulnerable(self) -> bool:
|
|
211
|
+
"""Quick pre-check with up to 5 repeats. Returns True if timing side-channel detected."""
|
|
212
|
+
quick_n = min(self.args.repeat, 5)
|
|
213
|
+
passwords = self._build_test_passwords()
|
|
214
|
+
baseline = self._measure_password("all_wrong", passwords["all_wrong"], repeat=quick_n)
|
|
215
|
+
last = self._measure_password("last_wrong", passwords["last_wrong"], repeat=quick_n)
|
|
216
|
+
vuln, _ = self._is_vulnerable(baseline, last)
|
|
217
|
+
return vuln
|
|
218
|
+
|
|
219
|
+
def _build_test_passwords(self) -> dict[str, str]:
|
|
220
|
+
password = self.args.password
|
|
221
|
+
length = len(password)
|
|
222
|
+
wrong = self.args.wrong_char
|
|
223
|
+
|
|
224
|
+
if length == 0:
|
|
225
|
+
raise ValueError("Password (-p) must not be empty.")
|
|
226
|
+
|
|
227
|
+
all_wrong = wrong * length
|
|
228
|
+
first_wrong = wrong + password[1:] if length > 1 else wrong
|
|
229
|
+
last_wrong = password[:-1] + wrong if length > 1 else wrong
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"all_wrong": all_wrong,
|
|
233
|
+
"first_wrong": first_wrong,
|
|
234
|
+
"last_wrong": last_wrong,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _threshold_ms(self, baseline_ms: float) -> float:
|
|
238
|
+
percent_threshold = baseline_ms * (self.args.threshold_percent / 100)
|
|
239
|
+
return max(percent_threshold, self.args.threshold_ms)
|
|
240
|
+
|
|
241
|
+
def _is_vulnerable(self, baseline: AttemptResult, candidate: AttemptResult) -> tuple[bool, float]:
|
|
242
|
+
"""Return (is_vulnerable, p_value).
|
|
243
|
+
|
|
244
|
+
Uses Mann-Whitney U test for statistical significance combined with a
|
|
245
|
+
minimum median-delta guard, so that pure network jitter does not
|
|
246
|
+
trigger false positives on low-latency connections.
|
|
247
|
+
"""
|
|
248
|
+
delta = candidate.median_ms - baseline.median_ms
|
|
249
|
+
threshold = self._threshold_ms(baseline.median_ms)
|
|
250
|
+
if delta <= threshold:
|
|
251
|
+
return False, 1.0
|
|
252
|
+
|
|
253
|
+
if len(baseline.samples_ms) < 3 or len(candidate.samples_ms) < 3:
|
|
254
|
+
return True, 0.0
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
_, p_value = mannwhitneyu(
|
|
258
|
+
candidate.samples_ms,
|
|
259
|
+
baseline.samples_ms,
|
|
260
|
+
alternative="greater",
|
|
261
|
+
)
|
|
262
|
+
except ValueError:
|
|
263
|
+
return True, 0.0
|
|
264
|
+
|
|
265
|
+
p_value = float(p_value)
|
|
266
|
+
return p_value < self.args.p_value, p_value
|
|
267
|
+
|
|
268
|
+
LABELS: dict[str, str] = {
|
|
269
|
+
"all_wrong": "wrong password",
|
|
270
|
+
"first_wrong": "first char off",
|
|
271
|
+
"last_wrong": "last char off",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_C_RESET = "\033[0m"
|
|
275
|
+
_C_CYAN = "\033[96m"
|
|
276
|
+
_C_RED = "\033[31m"
|
|
277
|
+
_C_GREEN = "\033[92m"
|
|
278
|
+
_C_YELLOW = "\033[93m"
|
|
279
|
+
_C_GREY = "\033[90m"
|
|
280
|
+
|
|
281
|
+
def _print_separator(self, width: int = 68) -> None:
|
|
282
|
+
print(f" {'─' * width}")
|
|
283
|
+
|
|
284
|
+
def _run_detection(self) -> None:
|
|
285
|
+
passwords = self._build_test_passwords()
|
|
286
|
+
results: list[AttemptResult] = []
|
|
287
|
+
not_json = not self.args.json
|
|
288
|
+
W = 18
|
|
289
|
+
|
|
290
|
+
if not_json:
|
|
291
|
+
ptprint(f"Target : {self.spec.method} {self.spec.url}", "TITLE", condition=True, colortext=True)
|
|
292
|
+
if self.args.verbose:
|
|
293
|
+
ptprint(
|
|
294
|
+
f"Password : {len(self.args.password)} chars | "
|
|
295
|
+
f"repeats: {self.args.repeat} | "
|
|
296
|
+
f"threshold: {self.args.threshold_percent}% | "
|
|
297
|
+
f"p ≤ {self.args.p_value}",
|
|
298
|
+
"ADDITIONS", condition=True, indent=4, colortext=True,
|
|
299
|
+
)
|
|
300
|
+
ptprint(" ", "ADDITIONS", condition=True)
|
|
301
|
+
ptprint(f"{'Scenario':<{W}} {'Avg (ms)':>9} Samples", "ADDITIONS", condition=True, indent=4, colortext=True)
|
|
302
|
+
ptprint("─" * 68, "ADDITIONS", condition=True, indent=4, colortext=True)
|
|
303
|
+
|
|
304
|
+
for attempt_type, candidate in passwords.items():
|
|
305
|
+
result = self._measure_password(attempt_type, candidate)
|
|
306
|
+
results.append(result)
|
|
307
|
+
if not_json and self.args.verbose:
|
|
308
|
+
samples_str = " ".join(f"{s:.1f}" for s in result.samples_ms)
|
|
309
|
+
label = self.LABELS[attempt_type]
|
|
310
|
+
ptprint(f"{label:<{W}} {result.median_ms:>9.2f} {samples_str}", "ADDITIONS", condition=True, indent=4, colortext=True)
|
|
311
|
+
|
|
312
|
+
baseline = next(r for r in results if r.attempt_type == "all_wrong")
|
|
313
|
+
first = next(r for r in results if r.attempt_type == "first_wrong")
|
|
314
|
+
last = next(r for r in results if r.attempt_type == "last_wrong")
|
|
315
|
+
|
|
316
|
+
threshold = self._threshold_ms(baseline.median_ms)
|
|
317
|
+
first_vuln, first_p = self._is_vulnerable(baseline, first)
|
|
318
|
+
last_vuln, last_p = self._is_vulnerable(baseline, last)
|
|
319
|
+
vulnerable = first_vuln or last_vuln
|
|
320
|
+
|
|
321
|
+
if not_json:
|
|
322
|
+
print()
|
|
323
|
+
print(f" {'Scenario':<{W}} {'Δ (ms)':>9} {'p-value':>8} Result")
|
|
324
|
+
self._print_separator(50)
|
|
325
|
+
|
|
326
|
+
for result, vuln, p in (
|
|
327
|
+
(first, first_vuln, first_p),
|
|
328
|
+
(last, last_vuln, last_p),
|
|
329
|
+
):
|
|
330
|
+
delta = result.median_ms - baseline.median_ms
|
|
331
|
+
label = self.LABELS[result.attempt_type]
|
|
332
|
+
color = self._C_RED if vuln else self._C_GREEN
|
|
333
|
+
marker = "[✗] VULNERABLE" if vuln else "[✓] OK"
|
|
334
|
+
print(f" {label:<{W}} {delta:>+9.2f} {p:>8.3f} {color}{marker}{self._C_RESET}")
|
|
335
|
+
|
|
336
|
+
print()
|
|
337
|
+
if vulnerable:
|
|
338
|
+
ptprint(
|
|
339
|
+
"RESULT: Target is VULNERABLE to a password timing side-channel attack.",
|
|
340
|
+
"VULN", condition=True, colortext=True,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
ptprint(
|
|
344
|
+
"RESULT: No timing side-channel detected on this target.",
|
|
345
|
+
"OK", condition=True, colortext=True,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self._emit_json_detection(results, vulnerable, first_vuln, last_vuln, threshold, first_p, last_p)
|
|
349
|
+
|
|
350
|
+
def _pick_wrong_char(self, reference: str) -> str:
|
|
351
|
+
for candidate in (DEFAULT_WRONG_CHAR, "#", "!", "0", "z"):
|
|
352
|
+
if candidate not in reference:
|
|
353
|
+
return candidate
|
|
354
|
+
return "Q"
|
|
355
|
+
|
|
356
|
+
def _build_bruteforce_candidate(self, prefix: str, char: str, total_len: int) -> str:
|
|
357
|
+
wrong = self._pick_wrong_char(self.args.password)
|
|
358
|
+
suffix_len = total_len - len(prefix) - 1
|
|
359
|
+
if suffix_len < 0:
|
|
360
|
+
raise ValueError("Brute-force prefix longer than target password length.")
|
|
361
|
+
return prefix + char + (wrong * suffix_len)
|
|
362
|
+
|
|
363
|
+
def _run_brute_force(self) -> None:
|
|
364
|
+
total_len = len(self.args.password)
|
|
365
|
+
known = ""
|
|
366
|
+
discovered: list[tuple[int, str, float]] = []
|
|
367
|
+
not_json = not self.args.json
|
|
368
|
+
W = 8
|
|
369
|
+
|
|
370
|
+
if not_json:
|
|
371
|
+
ptprint(
|
|
372
|
+
f"{'Target':<{W}}: {self.spec.method} {self.spec.url}",
|
|
373
|
+
"TITLE", condition=True, colortext=True,
|
|
374
|
+
)
|
|
375
|
+
ptprint("", "INFO", condition=True)
|
|
376
|
+
ptprint(
|
|
377
|
+
f"{'Mode':<{W}} brute-force",
|
|
378
|
+
condition=True, indent=4,
|
|
379
|
+
)
|
|
380
|
+
ptprint(
|
|
381
|
+
f"{'Password':<{W}} {total_len} chars | "
|
|
382
|
+
f"charset: {len(self.args.charset)} | "
|
|
383
|
+
f"repeats: {self.args.repeat}", condition=True, indent=4,
|
|
384
|
+
)
|
|
385
|
+
ptprint("", "INFO", condition=True,)
|
|
386
|
+
ptprint("Running vulnerability pre-check...", "INFO", condition=True)
|
|
387
|
+
|
|
388
|
+
server_vulnerable = self._is_server_vulnerable()
|
|
389
|
+
|
|
390
|
+
if not server_vulnerable:
|
|
391
|
+
if not_json:
|
|
392
|
+
ptprint("", "TEXT", condition=True)
|
|
393
|
+
ptprint(
|
|
394
|
+
"WARNING: Target does not appear vulnerable to timing attacks.",
|
|
395
|
+
"WARNING", condition=True, colortext=True,
|
|
396
|
+
)
|
|
397
|
+
ptprint(
|
|
398
|
+
"Brute-force results may be unreliable.",
|
|
399
|
+
"WARNING", condition=True, indent=4, colortext=True,
|
|
400
|
+
)
|
|
401
|
+
ptprint("", "TEXT", condition=True)
|
|
402
|
+
try:
|
|
403
|
+
answer = input("Continue anyway? [y/N] ").strip().lower()
|
|
404
|
+
except (EOFError, KeyboardInterrupt):
|
|
405
|
+
answer = "n"
|
|
406
|
+
if answer != "y":
|
|
407
|
+
ptprint("Aborted.", "INFO", condition=True)
|
|
408
|
+
return
|
|
409
|
+
ptprint("", "TEXT", condition=True)
|
|
410
|
+
else:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
if not_json:
|
|
414
|
+
ptprint("Recovering password...", "INFO", condition=True)
|
|
415
|
+
|
|
416
|
+
for position in range(total_len):
|
|
417
|
+
best_char = ""
|
|
418
|
+
best_median = -1.0
|
|
419
|
+
char_results: list[tuple[str, float]] = []
|
|
420
|
+
|
|
421
|
+
for char in self.args.charset:
|
|
422
|
+
candidate = self._build_bruteforce_candidate(known, char, total_len)
|
|
423
|
+
result = self._measure_password(f"pos{position}_{char}", candidate)
|
|
424
|
+
char_results.append((char, result.median_ms))
|
|
425
|
+
if result.median_ms > best_median:
|
|
426
|
+
best_median = result.median_ms
|
|
427
|
+
best_char = char
|
|
428
|
+
|
|
429
|
+
known += best_char
|
|
430
|
+
discovered.append((position, best_char, best_median))
|
|
431
|
+
|
|
432
|
+
if not_json:
|
|
433
|
+
pos_label = f"Position {position + 1:>{len(str(total_len))}}/{total_len}"
|
|
434
|
+
line = f"{pos_label} → '{best_char}' {best_median:.2f} ms"
|
|
435
|
+
if self.args.verbose:
|
|
436
|
+
top = sorted(char_results, key=lambda item: item[1], reverse=True)[:5]
|
|
437
|
+
top_str = " ".join(f"{c}:{t:.1f}" for c, t in top)
|
|
438
|
+
line += f" top: {top_str}"
|
|
439
|
+
ptprint(line, "ADDITIONS", condition=True, indent=4, colortext=True)
|
|
440
|
+
|
|
441
|
+
matches = known == self.args.password
|
|
442
|
+
if not_json:
|
|
443
|
+
ptprint("", "TEXT", condition=True)
|
|
444
|
+
ptprint(f"Recovered password: {known}", "TITLE", condition=True, colortext=True)
|
|
445
|
+
ptprint(
|
|
446
|
+
f"Matches supplied password (-p): {'YES' if matches else 'NO'}",
|
|
447
|
+
"OK" if matches else "WARNING",
|
|
448
|
+
condition=True, colortext=True,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
self._emit_json_bruteforce(known, discovered, matches)
|
|
452
|
+
|
|
453
|
+
def _emit_json_detection(
|
|
454
|
+
self,
|
|
455
|
+
results: list[AttemptResult],
|
|
456
|
+
vulnerable: bool,
|
|
457
|
+
first_vuln: bool,
|
|
458
|
+
last_vuln: bool,
|
|
459
|
+
threshold: float,
|
|
460
|
+
first_p: float,
|
|
461
|
+
last_p: float,
|
|
462
|
+
) -> None:
|
|
463
|
+
if vulnerable:
|
|
464
|
+
self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
|
|
465
|
+
|
|
466
|
+
if not vulnerable:
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
|
|
470
|
+
self.ptjsonlib.add_properties(
|
|
471
|
+
{
|
|
472
|
+
"mode": "detection",
|
|
473
|
+
"method": self.spec.method,
|
|
474
|
+
"url": self.spec.url,
|
|
475
|
+
"passwordLength": len(self.args.password),
|
|
476
|
+
"repeatCount": self.args.repeat,
|
|
477
|
+
"thresholdPercent": self.args.threshold_percent,
|
|
478
|
+
"thresholdMs": self.args.threshold_ms,
|
|
479
|
+
"pValueThreshold": self.args.p_value,
|
|
480
|
+
"effectiveThresholdMs": round(threshold, 3),
|
|
481
|
+
"firstWrongVulnerable": first_vuln,
|
|
482
|
+
"firstWrongPValue": round(first_p, 4),
|
|
483
|
+
"lastWrongVulnerable": last_vuln,
|
|
484
|
+
"lastWrongPValue": round(last_p, 4),
|
|
485
|
+
"attempts": [
|
|
486
|
+
{
|
|
487
|
+
"type": result.attempt_type,
|
|
488
|
+
"passwordSample": result.password,
|
|
489
|
+
"medianMs": round(result.median_ms, 3),
|
|
490
|
+
"samplesMs": [round(sample, 3) for sample in result.samples_ms],
|
|
491
|
+
}
|
|
492
|
+
for result in results
|
|
493
|
+
],
|
|
494
|
+
}
|
|
495
|
+
)
|
|
496
|
+
self.ptjsonlib.set_status("finished")
|
|
497
|
+
ptprint(self.ptjsonlib.get_result_json(), "", self.args.json)
|
|
498
|
+
|
|
499
|
+
def _emit_json_bruteforce(
|
|
500
|
+
self,
|
|
501
|
+
recovered: str,
|
|
502
|
+
discovered: list[tuple[int, str, float]],
|
|
503
|
+
matches: bool,
|
|
504
|
+
) -> None:
|
|
505
|
+
if matches:
|
|
506
|
+
self.ptjsonlib.add_vulnerability("PTV-WEB-AUTH-TIMING")
|
|
507
|
+
|
|
508
|
+
self.ptjsonlib.add_properties(
|
|
509
|
+
{
|
|
510
|
+
"mode": "brute-force",
|
|
511
|
+
"method": self.spec.method,
|
|
512
|
+
"url": self.spec.url,
|
|
513
|
+
"recoveredPassword": recovered,
|
|
514
|
+
"matchesSuppliedPassword": matches,
|
|
515
|
+
"positions": [
|
|
516
|
+
{
|
|
517
|
+
"position": position + 1,
|
|
518
|
+
"character": char,
|
|
519
|
+
"medianMs": round(median_ms, 3),
|
|
520
|
+
}
|
|
521
|
+
for position, char, median_ms in discovered
|
|
522
|
+
],
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
self.ptjsonlib.set_status("finished")
|
|
526
|
+
ptprint(self.ptjsonlib.get_result_json(), "", self.args.json)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def get_help():
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
"description": [
|
|
533
|
+
"Test whether a login endpoint compares passwords in constant time.",
|
|
534
|
+
"Detects timing side-channels exploitable for password recovery.",
|
|
535
|
+
]
|
|
536
|
+
},
|
|
537
|
+
{"usage": ["ptpasstime <options>"]},
|
|
538
|
+
{
|
|
539
|
+
"usage_example": [
|
|
540
|
+
"ptpasstime -u http://127.0.0.1:5000/login/vulnerable "
|
|
541
|
+
"-d 'username=admin&password=INJECT' -p correctPassword -n 15",
|
|
542
|
+
"ptpasstime -u http://127.0.0.1:5000/login/vulnerable "
|
|
543
|
+
"-d 'username=admin&password=INJECT' -p correctPassword --brute-force",
|
|
544
|
+
"ptpasstime --request-file login.txt -p correctPassword -n 20",
|
|
545
|
+
]
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
"options": [
|
|
549
|
+
["-u", "--url", "<url>", "Login endpoint URL"],
|
|
550
|
+
["-d", "--data", "<post-data>", "POST body with INJECT placeholder for password"],
|
|
551
|
+
["-f", "--request-file", "<file|base64>", "Raw HTTP request file (alternative to -d)"],
|
|
552
|
+
["-p", "--password", "<password>", "Known/guessed correct password (reference length and chars)"],
|
|
553
|
+
["-n", "--repeat", "<n>", "Number of repetitions per attempt (default 10)"],
|
|
554
|
+
["", "--brute-force", "", "Recover password character-by-character via timing"],
|
|
555
|
+
["", "--placeholder", "<text>", "Password placeholder in request body (default INJECT)"],
|
|
556
|
+
["", "--threshold-percent", "<pct>", "Relative timing threshold in percent (default 15)"],
|
|
557
|
+
["", "--threshold-ms", "<ms>", "Minimum absolute timing threshold in ms (default 1)"],
|
|
558
|
+
["", "--p-value", "<p>", "Mann-Whitney significance threshold (default 0.01)"],
|
|
559
|
+
["", "--charset", "<chars>", "Character set for --brute-force mode"],
|
|
560
|
+
["", "--wrong-char", "<char>", "Wrong character for padding attempts (default X)"],
|
|
561
|
+
["", "--proxy", "<proxy>", "Set proxy (e.g. http://127.0.0.1:8080)"],
|
|
562
|
+
["-T", "--timeout", "<seconds>", "Set timeout (default 10)"],
|
|
563
|
+
["-a", "--user-agent", "<a>", "Set User-Agent header"],
|
|
564
|
+
["-c", "--cookie", "<cookie>", "Set cookie"],
|
|
565
|
+
["-H", "--headers", "<header:value>", "Set custom header(s)"],
|
|
566
|
+
["-r", "--redirects", "", "Follow redirects (default False)"],
|
|
567
|
+
["-C", "--cache", "", "Cache compatibility flag"],
|
|
568
|
+
["-vv", "--verbose", "", "Show sample timings (additions output)"],
|
|
569
|
+
["-v", "--version", "", "Show script version and exit"],
|
|
570
|
+
["-h", "--help", "", "Show this help message and exit"],
|
|
571
|
+
["-j", "--json", "", "Output in JSON format"],
|
|
572
|
+
]
|
|
573
|
+
},
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def parse_args() -> argparse.Namespace:
|
|
578
|
+
parser = argparse.ArgumentParser(add_help=False, description=f"{SCRIPTNAME} <options>")
|
|
579
|
+
parser.add_argument("-u", "--url", type=str)
|
|
580
|
+
parser.add_argument("-d", "--data", type=str, default=None)
|
|
581
|
+
parser.add_argument("-f", "--request-file", type=str, default=None)
|
|
582
|
+
parser.add_argument("-p", "--password", type=str, required=True)
|
|
583
|
+
parser.add_argument("-n", "--repeat", type=int, default=10)
|
|
584
|
+
parser.add_argument("--brute-force", action="store_true")
|
|
585
|
+
parser.add_argument("--placeholder", type=str, default=DEFAULT_PLACEHOLDER)
|
|
586
|
+
parser.add_argument("--threshold-percent", type=float, default=15.0)
|
|
587
|
+
parser.add_argument("--threshold-ms", type=float, default=1.0)
|
|
588
|
+
parser.add_argument("--p-value", type=float, default=0.01)
|
|
589
|
+
parser.add_argument("--charset", type=str, default=DEFAULT_CHARSET)
|
|
590
|
+
parser.add_argument("--wrong-char", type=str, default=DEFAULT_WRONG_CHAR)
|
|
591
|
+
parser.add_argument("--proxy", type=str)
|
|
592
|
+
parser.add_argument("-T", "--timeout", type=int, default=10)
|
|
593
|
+
parser.add_argument("-a", "--user-agent", type=str, default="Penterep Tools")
|
|
594
|
+
parser.add_argument("-c", "--cookie", type=str)
|
|
595
|
+
parser.add_argument("-H", "--headers", type=ptmisclib.pairs, nargs="+")
|
|
596
|
+
parser.add_argument("-vv", "--verbose", action="store_true")
|
|
597
|
+
parser.add_argument("-r", "--redirects", action="store_true")
|
|
598
|
+
parser.add_argument("-C", "--cache", action="store_true")
|
|
599
|
+
parser.add_argument("-j", "--json", action="store_true")
|
|
600
|
+
parser.add_argument("-v", "--version", action="version", version=f"{SCRIPTNAME} {__version__}")
|
|
601
|
+
|
|
602
|
+
parser.add_argument("--socket-address", type=str, default=None)
|
|
603
|
+
parser.add_argument("--socket-port", type=str, default=None)
|
|
604
|
+
parser.add_argument("--process-ident", type=str, default=None)
|
|
605
|
+
|
|
606
|
+
if len(sys.argv) == 1 or "-h" in sys.argv or "--help" in sys.argv:
|
|
607
|
+
ptprinthelper.help_print(get_help(), SCRIPTNAME, __version__)
|
|
608
|
+
sys.exit(0)
|
|
609
|
+
|
|
610
|
+
args = parser.parse_args()
|
|
611
|
+
|
|
612
|
+
if not args.url and not args.request_file:
|
|
613
|
+
parser.error("at least one of --url or --request-file is required")
|
|
614
|
+
if not args.request_file and args.data is None:
|
|
615
|
+
parser.error("-d/--data is required when --request-file is not used")
|
|
616
|
+
if args.repeat < 1:
|
|
617
|
+
parser.error("--repeat must be >= 1")
|
|
618
|
+
if len(args.wrong_char) != 1:
|
|
619
|
+
parser.error("--wrong-char must be a single character")
|
|
620
|
+
if not args.charset:
|
|
621
|
+
parser.error("--charset must not be empty")
|
|
622
|
+
|
|
623
|
+
if args.proxy:
|
|
624
|
+
args.proxy = {"http": args.proxy, "https": args.proxy}
|
|
625
|
+
else:
|
|
626
|
+
args.proxy = {}
|
|
627
|
+
|
|
628
|
+
args.headers = ptnethelper.get_request_headers(args)
|
|
629
|
+
ptprinthelper.print_banner(SCRIPTNAME, __version__, args.json)
|
|
630
|
+
return args
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def main() -> None:
|
|
634
|
+
global SCRIPTNAME
|
|
635
|
+
SCRIPTNAME = "ptpasstime"
|
|
636
|
+
requests.packages.urllib3.disable_warnings()
|
|
637
|
+
args = parse_args()
|
|
638
|
+
try:
|
|
639
|
+
script = PtPassTime(args)
|
|
640
|
+
script.run()
|
|
641
|
+
except (ValueError, RuntimeError) as exc:
|
|
642
|
+
ptprint(str(exc), "ERROR", condition=not args.json)
|
|
643
|
+
sys.exit(1)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
if __name__ == "__main__":
|
|
647
|
+
main()
|