mirror of
https://github.com/jakeotte/klist2ccache
synced 2026-08-09 12:42:04 +00:00
Fix expiry check from #2 and rework dump/list output
PR #2 added ticket end/renew reporting and expired-ticket skipping. Three bugs came with it: * klistwinrm.py called time.time() without importing time -- every dump died with NameError on the first ticket. * The skip was nested inside `if info["renew_till"]:`. parse_time() returns 0 for an absent field, so non-renewable tickets were never checked and expired ones got written anyway -- the exact case the PR set out to catch. * parse_time() tags klist's "(local)" wallclock as UTC, so its values are target-local wallclock, not true epochs. Comparing them to time.time() skewed every check by the UTC offset: valid tickets dropped west of UTC, expired ones kept east of it. Expiry now keys off end_time via now_ticket_frame(), which builds "now" in the same frame parse_time() uses, so no time import is needed. A ticket past end_time but still within renew_till is written rather than dropped, since the ccache can be renewed. Output follows PRTremote's convention: [*]/[X] markers, dot-aligned kv rows, unmarked list rows, and a closing summary with the KRB5CCNAME hint. Skipped expired tickets are counted so "0 ccaches written" explains itself instead of just exiting 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63910dc6ae
commit
9d232aa480
+108
-29
@@ -38,6 +38,71 @@ from impacket.dcerpc.v5.rpcrt import (
|
||||
)
|
||||
|
||||
|
||||
# ─── output ───────────────────────────────────────────────────────────────────
|
||||
# Every line is "[*] ok/info" or "[X] absent/failed", no colors.
|
||||
|
||||
OK, BAD = "[*]", "[X]"
|
||||
_LABEL_WIDTH = 22
|
||||
|
||||
|
||||
class MarkerFormatter(logging.Formatter):
|
||||
"""Force impacket's log bullets ([*]/[+]/[!]/[-]) into just [*] and [X]."""
|
||||
|
||||
def __init__(self, ts=False):
|
||||
fmt = "%(marker)s %(asctime)s %(message)s" if ts else "%(marker)s %(message)s"
|
||||
super().__init__(fmt, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format(self, record):
|
||||
record.marker = BAD if record.levelno >= logging.WARNING else OK
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def init_logging(ts=False, debug=False):
|
||||
logger.init(ts)
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.setFormatter(MarkerFormatter(ts))
|
||||
logging.getLogger().setLevel(logging.DEBUG if debug else logging.INFO)
|
||||
|
||||
|
||||
def say(text, ok=True):
|
||||
"""One marked output line (stdout, not the log stream)."""
|
||||
print("%s %s" % (OK if ok else BAD, text))
|
||||
|
||||
|
||||
def kv(label, value, ok=True, indent=0):
|
||||
"""One marked, dot-aligned 'label ... value' line."""
|
||||
pad = " " * indent
|
||||
say("%s %s" % ((pad + label + " ").ljust(_LABEL_WIDTH, "."), value), ok)
|
||||
|
||||
|
||||
def item(text, indent=1):
|
||||
"""One unmarked list row, aligned under the marked lines around it."""
|
||||
print("%s%s%s" % (" " * (len(OK) + 1), " " * indent, text))
|
||||
|
||||
|
||||
def fmt_ticket_time(ts):
|
||||
"""Render a parsed klist timestamp for display.
|
||||
|
||||
_parse_klist tags klist's "(local)" wallclock as UTC, so rendering it back
|
||||
in UTC reproduces the wallclock the target itself reported.
|
||||
"""
|
||||
if not ts:
|
||||
return "N/A"
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def now_ticket_frame():
|
||||
""""Now" in the same frame as _parse_klist's parse_time().
|
||||
|
||||
parse_time() tags the target's local wallclock as UTC, so its values are not
|
||||
true epochs. Comparing them against time.time() skews every result by the
|
||||
UTC offset, which silently drops still-valid tickets west of UTC and keeps
|
||||
expired ones east of it. Build "now" the same way instead: naive local
|
||||
wallclock tagged UTC. Assumes operator and target share a timezone.
|
||||
"""
|
||||
return datetime.now().replace(tzinfo=timezone.utc).timestamp()
|
||||
|
||||
|
||||
# ─── klist text parser ────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_klist(text):
|
||||
@@ -430,7 +495,7 @@ def run_remote_cmd_and_read_output(smb, dce, command, max_wait=60, retries=20, p
|
||||
Returns decoded string content, or None on failure.
|
||||
"""
|
||||
task_name, task_author, task_desc, temp_basename = _leet_names(product=product)
|
||||
logging.info(" task: \\%s file: %s" % (task_name, temp_basename))
|
||||
logging.info("task: \\%s file: %s" % (task_name, temp_basename))
|
||||
|
||||
args = '/c "' + command + ' > C:\\ProgramData\\' + temp_basename + '"'
|
||||
xml = _task_xml(task_author, task_desc, "cmd.exe", args, time_limit="PT1M")
|
||||
@@ -504,7 +569,7 @@ def _run_ps_via_pipe(smb, dce, ps_body, max_wait=90, pipe_timeout=45):
|
||||
Returns raw output string (before EOF sentinel), or None on failure.
|
||||
"""
|
||||
task_name, task_author, task_desc, pipe_name = _leet_names(use_pipes=True)
|
||||
logging.info(" task: \\%s pipe: \\pipe\\%s" % (task_name, pipe_name))
|
||||
logging.info("task: \\%s pipe: \\pipe\\%s" % (task_name, pipe_name))
|
||||
|
||||
ps_cmd = (
|
||||
"$n='{pipe}';"
|
||||
@@ -791,11 +856,12 @@ def cmd_list(args):
|
||||
sys.exit(0)
|
||||
|
||||
print()
|
||||
print(" Kerberos sessions on %s:\n" % address)
|
||||
kv("Kerberos sessions", len(sessions), ok=bool(sessions))
|
||||
w = max(len(a) for _, a in sessions)
|
||||
for i, (logon_hex, account) in enumerate(sessions, 1):
|
||||
print(" [%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
item("[%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
print()
|
||||
say("Dump one with: -s N (omit -s to dump all)")
|
||||
|
||||
|
||||
def cmd_dump(args):
|
||||
@@ -876,10 +942,9 @@ def cmd_dump(args):
|
||||
|
||||
w = max(len(a) for _, a in to_dump)
|
||||
print()
|
||||
print(" Sessions to dump:\n")
|
||||
kv("Sessions to dump", len(to_dump))
|
||||
for i, (logon_hex, account) in enumerate(to_dump, 1):
|
||||
print(" [%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
print()
|
||||
item("[%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
|
||||
# ── Fetch TGT text(s) ────────────────────────────────────────────────────
|
||||
if args.named_pipes:
|
||||
@@ -900,32 +965,39 @@ def cmd_dump(args):
|
||||
|
||||
# ── Write ccache files ───────────────────────────────────────────────────
|
||||
written = []
|
||||
expired = 0
|
||||
for i, ((logon_hex, account), tgt_text) in enumerate(zip(to_dump, tgt_texts), 1):
|
||||
print()
|
||||
logging.info("[%d/%d] %s (%s) ..." % (i, len(to_dump), account, logon_hex))
|
||||
say("[%d/%d] %s (%s)" % (i, len(to_dump), account, logon_hex))
|
||||
if not tgt_text:
|
||||
logging.error(" No output for %s" % account)
|
||||
kv("Result", "no output", ok=False, indent=1)
|
||||
continue
|
||||
info = _parse_klist(tgt_text)
|
||||
if not info["ticket_data"]:
|
||||
logging.error(" No ticket data found for %s" % account)
|
||||
kv("Result", "no ticket data found", ok=False, indent=1)
|
||||
continue
|
||||
if info.get("cred_guard"):
|
||||
logging.error(" %s: session key is Credential Guard-protected (wrapped in VTL1); "
|
||||
"cannot export a usable ccache. Skipping." % account)
|
||||
kv("Result", "Credential Guard-protected (VTL1) - cannot export",
|
||||
ok=False, indent=1)
|
||||
continue
|
||||
|
||||
now = time.time()
|
||||
now = now_ticket_frame()
|
||||
end_time, renew_till = info["end_time"], info["renew_till"]
|
||||
|
||||
if info["end_time"]:
|
||||
etime = datetime.fromtimestamp(info["end_time"]).isoformat()
|
||||
logging.info(" EndTime: %s", etime)
|
||||
kv("EndTime", fmt_ticket_time(end_time), indent=1)
|
||||
if renew_till:
|
||||
kv("RenewUntil", fmt_ticket_time(renew_till), indent=1)
|
||||
|
||||
if info["renew_till"]:
|
||||
rtime = datetime.fromtimestamp(info["renew_till"]).isoformat()
|
||||
logging.info(" Renew Until: %s", rtime)
|
||||
if info["renew_till"] < now:
|
||||
logging.info(" Expired Ticket!")
|
||||
# Expiry is decided by end_time. renew_till only earns a ticket a
|
||||
# reprieve: past end_time but still renewable is worth writing, since
|
||||
# the ccache can be renewed. A non-renewable ticket has renew_till == 0,
|
||||
# which must not be mistaken for "no expiry information".
|
||||
if end_time and end_time < now:
|
||||
if renew_till and renew_till > now:
|
||||
kv("Status", "expired, still renewable - writing", ok=False, indent=1)
|
||||
else:
|
||||
kv("Status", "expired - skipping", ok=False, indent=1)
|
||||
expired += 1
|
||||
continue
|
||||
|
||||
safe_name = re.sub(r"[^\w@.-]", "_", "%s@%s" % (info["client"], info["realm"]))
|
||||
@@ -937,12 +1009,21 @@ def cmd_dump(args):
|
||||
idx2 += 1
|
||||
_write_ccache(info, out_path)
|
||||
written.append(out_path)
|
||||
logging.info(" -> %s" % out_path)
|
||||
kv("Written", out_path, indent=1)
|
||||
|
||||
print()
|
||||
if written:
|
||||
logging.info("Done. %d ccache(s) written to %s" % (len(written), args.output_dir))
|
||||
kv("ccaches written", "%d -> %s" % (len(written), args.output_dir))
|
||||
if expired:
|
||||
kv("Skipped (expired)", expired, ok=False)
|
||||
print()
|
||||
say("Use with: export KRB5CCNAME=%s" % written[0])
|
||||
else:
|
||||
logging.error("No ccache files written")
|
||||
kv("ccaches written", "0", ok=False)
|
||||
if expired:
|
||||
kv("Skipped (expired)", expired, ok=False)
|
||||
print()
|
||||
say("Every ticket dumped was expired - nothing usable to write.", ok=False)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -999,14 +1080,12 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
print(version.BANNER)
|
||||
logger.init(args.ts)
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
init_logging(args.ts, args.debug)
|
||||
|
||||
if getattr(args, "named_pipes", False):
|
||||
logging.warning("This will work ONLY on Windows >= Vista with PowerShell 2.0+")
|
||||
logging.info("Requires Windows >= Vista with PowerShell 2.0+ on the target")
|
||||
else:
|
||||
logging.warning("This will work ONLY on Windows >= Vista")
|
||||
logging.info("Requires Windows >= Vista on the target")
|
||||
|
||||
if args.mode == "list":
|
||||
cmd_list(args)
|
||||
|
||||
+104
-25
@@ -31,6 +31,71 @@ from impacket.examples import logger
|
||||
from impacket.examples.utils import parse_target
|
||||
|
||||
|
||||
# ─── output ───────────────────────────────────────────────────────────────────
|
||||
# Every line is "[*] ok/info" or "[X] absent/failed", no colors.
|
||||
|
||||
OK, BAD = "[*]", "[X]"
|
||||
_LABEL_WIDTH = 22
|
||||
|
||||
|
||||
class MarkerFormatter(logging.Formatter):
|
||||
"""Force impacket's log bullets ([*]/[+]/[!]/[-]) into just [*] and [X]."""
|
||||
|
||||
def __init__(self, ts=False):
|
||||
fmt = "%(marker)s %(asctime)s %(message)s" if ts else "%(marker)s %(message)s"
|
||||
super().__init__(fmt, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format(self, record):
|
||||
record.marker = BAD if record.levelno >= logging.WARNING else OK
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def init_logging(ts=False, debug=False):
|
||||
logger.init(ts)
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.setFormatter(MarkerFormatter(ts))
|
||||
logging.getLogger().setLevel(logging.DEBUG if debug else logging.INFO)
|
||||
|
||||
|
||||
def say(text, ok=True):
|
||||
"""One marked output line (stdout, not the log stream)."""
|
||||
print("%s %s" % (OK if ok else BAD, text))
|
||||
|
||||
|
||||
def kv(label, value, ok=True, indent=0):
|
||||
"""One marked, dot-aligned 'label ... value' line."""
|
||||
pad = " " * indent
|
||||
say("%s %s" % ((pad + label + " ").ljust(_LABEL_WIDTH, "."), value), ok)
|
||||
|
||||
|
||||
def item(text, indent=1):
|
||||
"""One unmarked list row, aligned under the marked lines around it."""
|
||||
print("%s%s%s" % (" " * (len(OK) + 1), " " * indent, text))
|
||||
|
||||
|
||||
def fmt_ticket_time(ts):
|
||||
"""Render a parsed klist timestamp for display.
|
||||
|
||||
_parse_klist tags klist's "(local)" wallclock as UTC, so rendering it back
|
||||
in UTC reproduces the wallclock the target itself reported.
|
||||
"""
|
||||
if not ts:
|
||||
return "N/A"
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def now_ticket_frame():
|
||||
""""Now" in the same frame as _parse_klist's parse_time().
|
||||
|
||||
parse_time() tags the target's local wallclock as UTC, so its values are not
|
||||
true epochs. Comparing them against time.time() skews every result by the
|
||||
UTC offset, which silently drops still-valid tickets west of UTC and keeps
|
||||
expired ones east of it. Build "now" the same way instead: naive local
|
||||
wallclock tagged UTC. Assumes operator and target share a timezone.
|
||||
"""
|
||||
return datetime.now().replace(tzinfo=timezone.utc).timestamp()
|
||||
|
||||
|
||||
# ─── klist text parser ────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_klist(text):
|
||||
@@ -380,11 +445,12 @@ def cmd_list(args):
|
||||
sys.exit(0)
|
||||
|
||||
print()
|
||||
print(" Kerberos sessions on %s:\n" % address)
|
||||
kv("Kerberos sessions", len(sessions), ok=bool(sessions))
|
||||
w = max(len(a) for _, a in sessions)
|
||||
for i, (logon_hex, account) in enumerate(sessions, 1):
|
||||
print(" [%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
item("[%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
print()
|
||||
say("Dump one with: -s N (omit -s to dump all)")
|
||||
|
||||
|
||||
def cmd_dump(args):
|
||||
@@ -419,18 +485,18 @@ def cmd_dump(args):
|
||||
|
||||
w = max(len(a) for _, a in to_dump)
|
||||
print()
|
||||
print(" Sessions to dump:\n")
|
||||
kv("Sessions to dump", len(to_dump))
|
||||
for i, (logon_hex, account) in enumerate(to_dump, 1):
|
||||
print(" [%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
print()
|
||||
item("[%d] %-*s %s" % (i, w, account, logon_hex))
|
||||
|
||||
written = []
|
||||
expired = 0
|
||||
for i, (logon_hex, account) in enumerate(to_dump, 1):
|
||||
print()
|
||||
logging.info("[%d/%d] Dumping TGT for %s (%s) ..." % (i, len(to_dump), account, logon_hex))
|
||||
say("[%d/%d] %s (%s)" % (i, len(to_dump), account, logon_hex))
|
||||
tgt_text = _run_cmd(session, "klist tgt -li %s" % logon_hex)
|
||||
if not tgt_text:
|
||||
logging.error(" No output for %s" % account)
|
||||
kv("Result", "no output", ok=False, indent=1)
|
||||
continue
|
||||
|
||||
if args.debug:
|
||||
@@ -438,25 +504,31 @@ def cmd_dump(args):
|
||||
|
||||
info = _parse_klist(tgt_text)
|
||||
if not info["ticket_data"]:
|
||||
logging.error(" No ticket data found for %s" % account)
|
||||
kv("Result", "no ticket data found", ok=False, indent=1)
|
||||
continue
|
||||
|
||||
if info.get("cred_guard"):
|
||||
logging.error(" %s: session key is Credential Guard-protected (wrapped in VTL1); "
|
||||
"cannot export a usable ccache. Skipping." % account)
|
||||
kv("Result", "Credential Guard-protected (VTL1) - cannot export",
|
||||
ok=False, indent=1)
|
||||
continue
|
||||
|
||||
now = time.time()
|
||||
now = now_ticket_frame()
|
||||
end_time, renew_till = info["end_time"], info["renew_till"]
|
||||
|
||||
if info["end_time"]:
|
||||
etime = datetime.fromtimestamp(info["end_time"]).isoformat()
|
||||
logging.info(" EndTime: %s", etime)
|
||||
kv("EndTime", fmt_ticket_time(end_time), indent=1)
|
||||
if renew_till:
|
||||
kv("RenewUntil", fmt_ticket_time(renew_till), indent=1)
|
||||
|
||||
if info["renew_till"]:
|
||||
rtime = datetime.fromtimestamp(info["renew_till"]).isoformat()
|
||||
logging.info(" Renew Until: %s", rtime)
|
||||
if info["renew_till"] < now:
|
||||
logging.info(" Expired Ticket!")
|
||||
# Expiry is decided by end_time. renew_till only earns a ticket a
|
||||
# reprieve: past end_time but still renewable is worth writing, since
|
||||
# the ccache can be renewed. A non-renewable ticket has renew_till == 0,
|
||||
# which must not be mistaken for "no expiry information".
|
||||
if end_time and end_time < now:
|
||||
if renew_till and renew_till > now:
|
||||
kv("Status", "expired, still renewable - writing", ok=False, indent=1)
|
||||
else:
|
||||
kv("Status", "expired - skipping", ok=False, indent=1)
|
||||
expired += 1
|
||||
continue
|
||||
|
||||
safe_name = re.sub(r"[^\w@.-]", "_", "%s@%s" % (info["client"], info["realm"]))
|
||||
@@ -469,12 +541,21 @@ def cmd_dump(args):
|
||||
|
||||
_write_ccache(info, out_path)
|
||||
written.append(out_path)
|
||||
logging.info(" -> %s" % out_path)
|
||||
kv("Written", out_path, indent=1)
|
||||
|
||||
print()
|
||||
if written:
|
||||
logging.info("Done. %d ccache(s) written to %s" % (len(written), args.output_dir))
|
||||
kv("ccaches written", "%d -> %s" % (len(written), args.output_dir))
|
||||
if expired:
|
||||
kv("Skipped (expired)", expired, ok=False)
|
||||
print()
|
||||
say("Use with: export KRB5CCNAME=%s" % written[0])
|
||||
else:
|
||||
logging.error("No ccache files written")
|
||||
kv("ccaches written", "0", ok=False)
|
||||
if expired:
|
||||
kv("Skipped (expired)", expired, ok=False)
|
||||
print()
|
||||
say("Every ticket dumped was expired - nothing usable to write.", ok=False)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -525,9 +606,7 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
print(version.BANNER)
|
||||
logger.init(args.ts)
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
init_logging(args.ts, args.debug)
|
||||
|
||||
if args.mode == "list":
|
||||
cmd_list(args)
|
||||
|
||||
Reference in New Issue
Block a user