Ethical Hacking (methodology, lab-only) · lesson 4 of 6
Beginner
3Intermediate
3Also part of Ethical Hacking (methodology, lab-only)
12- + Port Scanning Your Own Lab with nmap: Open, Closed, Filtered from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + CSRF: Synchronizer Tokens and SameSite Cookies, Fixed and Tested from Web Security (OWASP Top 10, fix-side)
- + SSRF: Allowlists, Resolved-IP Checks and No Redirects from Web Security (OWASP Top 10, fix-side)
- + Security Misconfiguration: Error Pages, Debug Mode and Security Headers from Web Security (OWASP Top 10, fix-side)
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Authentication Failures: Username Enumeration and Login Rate Limiting from Web Security (OWASP Top 10, fix-side)
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + Password Hashing Work Factors, Measured from Cryptography
- + DAST with an OWASP ZAP Baseline Scan Against Your Own App from DevSecOps (AppSec in the pipeline)
Logging Test Activity and Hashing the Evidence
Keep a pentest activity log in UTC tied to the signed scope, hash every evidence file with SHA-256, and catch a one-byte change with a verify step.
By SkillByExample · Published
The findings report says what you found. The activity log says what you did: every scan, request and check, with the time, the target and the engagement it belongs to. If a client’s server falls over at 10:40 on Wednesday, the log answers whether you were testing it then. The evidence behind each finding gets the same care, with a SHA-256 hash recorded when it’s collected, so anyone can check later that it hasn’t changed.
The examples continue the ACME-2026-10 engagement from the
scope lesson, and read the same scope.yaml.
They use Python 3.12 and PyYAML 6.0.3 on Linux, with GNU sha256sum and sed. Nothing here
contacts a host. The commands in the log are strings the tester records, not commands this
lesson runs.
The same moment, two different times
Two testers on the same engagement each note the time of one action, using their laptop’s clock.
# clock.py: one instant, written down by two testers' laptops
from datetime import datetime, timezone
instant = 1792578300 # a single moment in time, as a Unix timestamp
local = datetime.fromtimestamp(instant)
utc = datetime.fromtimestamp(instant, timezone.utc)
print(f"local: {local.isoformat()}")
print(f"utc: {utc.isoformat()}")
$ TZ=Europe/London python clock.py
local: 2026-10-21T11:25:00
utc: 2026-10-21T10:25:00+00:00
$ TZ=America/New_York python clock.py
local: 2026-10-21T06:25:00
utc: 2026-10-21T10:25:00+00:00
datetime.fromtimestamp without a timezone returns a naive local time with no offset. London is
on summer time in October, so it says 11:25, and New York says 06:25. Neither string records which
zone it came from. Put both in one log and the same action looks like two events five hours
apart. The UTC line is identical on both machines and carries its +00:00 offset, so anyone can
convert it back. Client firewall and SIEM logs are often kept in UTC too, so a UTC log lines up
with theirs. Confirm the client’s log timezone at kickoff, so you know which way to convert if
they aren’t.
One line per action
The log is a JSON Lines file: one JSON object per line, appended and never rewritten. Each entry
carries the engagement ID from scope.yaml, so a log can’t be mistaken for another client’s.
LOG_NOW pins the clock for reproducible output, as SCOPE_NOW did in the scope lesson.
# log_action.py: append one tester action to activity.jsonl, timestamped in UTC
import json
import os
import sys
from datetime import datetime, timezone
import yaml
scope = yaml.safe_load(open("scope.yaml"))
now = os.environ.get("LOG_NOW")
now = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
target, tool, command, result = sys.argv[1:5]
entry = {
"ts": now.astimezone(timezone.utc).isoformat(),
"engagement": scope["engagement"],
"tester": os.environ.get("TESTER", "unknown"),
"target": target, "tool": tool, "command": command, "result": result,
}
with open("activity.jsonl", "a", encoding="utf-8") as log:
log.write(json.dumps(entry) + "\n")
print(f"logged {entry['ts']} {entry['engagement']} {target} {tool}")
$ export TESTER=a.rao
$ LOG_NOW=2026-10-21T10:25:00+00:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"
logged 2026-10-21T10:25:00+00:00 ACME-2026-10 10.0.0.5 nmap
$ LOG_NOW=2026-10-21T11:40:00+01:00 python log_action.py 10.0.0.5 curl "curl -i http://10.0.0.5:8080/admin/users" "200, user list without auth"
logged 2026-10-21T10:40:00+00:00 ACME-2026-10 10.0.0.5 curl
$ cat activity.jsonl
{"ts": "2026-10-21T10:25:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "10.0.0.5", "tool": "nmap", "command": "nmap -sV -p 8080 10.0.0.5", "result": "8080/tcp open http"}
{"ts": "2026-10-21T10:40:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "10.0.0.5", "tool": "curl", "command": "curl -i http://10.0.0.5:8080/admin/users", "result": "200, user list without auth"}
The second action was given in London summer time, 11:40:00+01:00, and the log stored
10:40:00+00:00. astimezone(timezone.utc) converts any offset to UTC before writing, so every
line uses the same clock. Opening the file in append mode ("a") adds lines without touching the
earlier ones. The exact command goes in the log as well as the result, so a reviewer can repeat
it. In a real engagement, put the scope checker in front:
python scope_check.py "$t" && nmap ... "$t" && python log_action.py "$t" ... logs only what the
scope allowed.
Hash the evidence when you collect it
Evidence is the raw material behind a finding: a saved HTTP response, a header, a file listing.
Create two evidence files for F-01 and F-05 from the findings report. HTTP header lines end in
\r\n (carriage return, line feed), so the saved response does too.
# hash_evidence.py: record a SHA-256 for every evidence file in manifest.json
import hashlib
import json
from pathlib import Path
manifest = {}
for path in sorted(Path("evidence").rglob("*")):
if path.is_file():
digest = hashlib.sha256(path.read_bytes()).hexdigest()
manifest[path.as_posix()] = digest
print(f"{digest} {path.as_posix()}")
Path("manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(f"{len(manifest)} files hashed into manifest.json")
$ mkdir -p evidence
$ printf 'HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Type: application/json\r\n\r\n[{"id": 1, "name": "admin"}]\n' > evidence/F-01-admin-users.http
$ printf 'Server: nginx/1.24.0\n' > evidence/F-05-server-header.txt
$ python hash_evidence.py
ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1 evidence/F-01-admin-users.http
1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1 evidence/F-05-server-header.txt
2 files hashed into manifest.json
$ sha256sum evidence/*
ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1 evidence/F-01-admin-users.http
1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1 evidence/F-05-server-header.txt
The script prints in the same hash path layout as sha256sum, and the hashes match. That
matters: the client can check your manifest with a standard tool, without trusting your
script. sorted makes the manifest’s order stable from run to run. as_posix() writes the path
with forward slashes even on Windows, so the manifest checks the same way on any machine.
The evidence in a real report has its live credentials redacted before you hash it, because
the hash fixes the file as it was when you hashed it.
The text-mode trap
hash_evidence.py reads the files with read_bytes(). Opening the file normally looks
equivalent, but it isn’t.
# hash_text.py: the same evidence file, hashed two ways
import hashlib
path = "evidence/F-01-admin-users.http"
with open(path) as f: # text mode
text_digest = hashlib.sha256(f.read().encode()).hexdigest()
with open(path, "rb") as f: # binary mode
raw_digest = hashlib.sha256(f.read()).hexdigest()
print(f"text mode: {text_digest}")
print(f"binary mode: {raw_digest}")
with open(path) as f:
print(f"carriage returns seen in text mode: {f.read().count(chr(13))}")
$ python hash_text.py
text mode: 354cf88f2bd14592a33f407964eb49c01da0578397e90effe84e693dee5f4528
binary mode: ec806b226f096ec5bf1e7df414fa7bae70da22ae53bc7a0c187fc4c077f0aed1
carriage returns seen in text mode: 0
Only the binary-mode hash matches sha256sum. Text mode uses universal newlines: on reading, it
translates every \r\n into \n, so the four carriage returns in the response vanish before
the hash sees them. The text-mode hash is a correct hash of a file that doesn’t exist. Plain
.txt evidence with only \n endings would hash the same both ways, which is why this bug
passes a quick test and then fails on the first saved HTTP response. Hash bytes, never text.
Verify, and fail on a single byte
Before you write the report, and again before you hand evidence over, recompute every hash and compare it with the manifest. The exit code makes it usable as a gate.
# verify_evidence.py: fail if any evidence file changed since it was hashed
import hashlib
import json
import sys
from pathlib import Path
manifest = json.loads(Path("manifest.json").read_text())
problems = 0
for name, expected in manifest.items():
path = Path(name)
if not path.is_file():
print(f"MISSING {name}")
problems += 1
continue
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual == expected:
print(f"OK {name}")
else:
print(f"CHANGED {name}\n expected {expected}\n actual {actual}")
problems += 1
sys.exit(1 if problems else 0)
$ python verify_evidence.py; echo "exit=$?"
OK evidence/F-01-admin-users.http
OK evidence/F-05-server-header.txt
exit=0
$ sed -i 's/1.24.0/1.24.1/' evidence/F-05-server-header.txt
$ python verify_evidence.py; echo "exit=$?"
OK evidence/F-01-admin-users.http
CHANGED evidence/F-05-server-header.txt
expected 1fa87d163c3fe10b25c86adfbba59244ce691839e00b272b25b543393be2f2c1
actual 79082989e11455109090219e2df4c63d6b0e67dfe1a736c24e52e0b50a18038e
exit=1
The sed changed one character, 0 to 1, and the whole hash changed. A cryptographic hash
gives no hint of how big the change was: one byte or a whole page produces an equally different
value. Separately, SHA-256 is designed so nobody can construct a different file with the same
hash, so an altered file can’t be adjusted until it matches the manifest again. The verifier says which file changed and exits 1, but it can’t say what the
file used to contain. Keep the original evidence read-only, and keep a copy somewhere the
working directory can’t touch.
The manifest is only as trustworthy as its own copy. If someone can edit both a file and
manifest.json, the check passes. Record the manifest’s own hash where you can’t quietly change
it: in the activity log, and in the end-of-day email to the client’s contact.
Practice
- Restore the tampered file from your read-only copy (here,
sedcan undo the one-byte change), then add a new file toevidence/.verify_evidence.pystill passes, because it only checks the files the manifest lists. Make it report extra files too.
Solution
Add this loop just before sys.exit:
for path in sorted(Path("evidence").rglob("*")):
if path.is_file() and path.as_posix() not in manifest:
print(f"EXTRA {path.as_posix()}")
problems += 1
$ sed -i 's/1.24.1/1.24.0/' evidence/F-05-server-header.txt
$ printf 'GET /debug returned 200\n' > evidence/F-07-debug.txt
$ python verify_evidence.py; echo "exit=$?"
OK evidence/F-01-admin-users.http
OK evidence/F-05-server-header.txt
EXTRA evidence/F-07-debug.txt
exit=1
An extra file isn’t necessarily tampering. Often it’s new evidence nobody hashed yet. It still fails the check, because evidence without a recorded hash can’t be shown to be original. Hash it, and log that you did.
- A tester in New York sets
LOG_NOW=2026-10-21T10:25:00, without an offset, meaning 10:25 UTC. What does the log record? Makelog_action.pyrefuse a time with no offset.
Solution
$ export TESTER=a.rao
$ TZ=America/New_York LOG_NOW=2026-10-21T10:25:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"
logged 2026-10-21T14:25:00+00:00 ACME-2026-10 10.0.0.5 nmap
fromisoformat returns a naive datetime, and astimezone treats a naive value as the machine’s
local time. The action is logged four hours late. Refuse naive times after parsing:
if now.tzinfo is None:
sys.exit("LOG_NOW needs a UTC offset, for example 2026-10-21T10:25:00+00:00")
$ TZ=America/New_York LOG_NOW=2026-10-21T10:25:00 python log_action.py 10.0.0.5 nmap "nmap -sV -p 8080 10.0.0.5" "8080/tcp open http"; echo "exit=$?"
LOG_NOW needs a UTC offset, for example 2026-10-21T10:25:00+00:00
exit=1
The real clock path is already safe, because datetime.now(timezone.utc) is timezone-aware.
- Log two more actions, then write
summary.pyto print, for each target, how many actions it received and the first and last timestamp.
Solution
# summary.py: how many actions hit each target, and between which times
import json
from collections import defaultdict
stamps = defaultdict(list)
for line in open("activity.jsonl", encoding="utf-8"):
entry = json.loads(line)
stamps[entry["target"]].append(entry["ts"])
for target, ts in sorted(stamps.items()):
print(f"{target}: {len(ts)} logged, first {min(ts)}, last {max(ts)}")
$ LOG_NOW=2026-10-21T13:05:00+00:00 python log_action.py 172.20.0.10 nmap "nmap -sV -p 443 172.20.0.10" "443/tcp open https"
logged 2026-10-21T13:05:00+00:00 ACME-2026-10 172.20.0.10 nmap
$ LOG_NOW=2026-10-22T09:15:00+00:00 python log_action.py 10.0.0.5 curl "curl -I http://10.0.0.5:8080/" "Server: nginx/1.24.0"
logged 2026-10-22T09:15:00+00:00 ACME-2026-10 10.0.0.5 curl
$ python summary.py
10.0.0.5: 4 logged, first 2026-10-21T10:25:00+00:00, last 2026-10-22T09:15:00+00:00
172.20.0.10: 1 logged, first 2026-10-21T13:05:00+00:00, last 2026-10-21T13:05:00+00:00
10.0.0.5 shows 4 entries, not 3: the wrongly timed 14:25 line from task 2 is still in the log.
Leave it there. An append-only log is corrected by appending a note that says which entry was
wrong and why, never by deleting a line. min and max compare the timestamps as strings,
which works only because every entry is UTC in the same ISO 8601 format. Mixed offsets would
sort wrongly, which is one more reason to convert to UTC as you write each line. This summary is
what goes in the report’s methodology section: which hosts were tested, and when.
- At the end of the day, record the manifest’s hash in the activity log, so a later edit to
manifest.jsonwould be visible.
Solution
$ export TESTER=a.rao LOG_NOW=2026-10-22T17:00:00+00:00
$ python log_action.py evidence sha256sum "sha256sum manifest.json" "$(sha256sum manifest.json | cut -c1-64)"
logged 2026-10-22T17:00:00+00:00 ACME-2026-10 evidence sha256sum
$ tail -n 1 activity.jsonl
{"ts": "2026-10-22T17:00:00+00:00", "engagement": "ACME-2026-10", "tester": "a.rao", "target": "evidence", "tool": "sha256sum", "command": "sha256sum manifest.json", "result": "5f9c5e8bffa6bc20957d4f9e7485c4a70727a91d7130623b0b5b50af47cd44fe"}
cut -c1-64 keeps just the 64 hex characters of the hash. The log is on your machine, so on its
own this only moves the problem. Send the same hash to the client’s technical contact in the
end-of-day email. Once it’s in their inbox, neither side can change the manifest without the
change showing.