by C. Casquatch
A personal cheat sheet of the tools/commands used across the PentesterLab Recon series, compiled from public write-ups, tool docs, and my own notes as I go. No >answers/keys here — just the “how” so I can reapply these techniques to other targets and exercises.
Concept: robots.txt tells crawlers what not to index. Webmasters use the Disallow: directive to keep specific pages out of search results — but that same directive is a direct hint to a human reader about which paths the owner considers sensitive enough to hide from Google, which is exactly why it’s the first thing worth checking on any target.
curl https://target.tld/robots.txt
or just visit https://target.tld/robots.txt in a browser.
What to look for: Any Disallow: line. A single Disallow: / means the entire site is telling crawlers to stay out — unusual for a normal public site, and often a sign the site is under development, staging, or otherwise not meant to be publicly indexed at all. More specific disallowed paths (Disallow: /admin/, Disallow: /internal-docs/) are even more directly useful, since they’re pointing you straight at something the owner didn’t want search engines to find.
Bonus/alternative discovery method: if robots.txt doesn’t exist or is empty, you can also try crawling/brute-forcing the site itself with a directory tool (see Recon 05) to find similarly “hidden” paths the same way a crawler would — robots.txt is really just the site owner’s own list of what not to crawl, so it’s the fastest possible shortcut before you resort to brute force.
Concept: Error pages can leak server software, versions, or internal paths, and also give you a baseline for what a genuinely nonexistent page looks like on this specific server — which matters later when brute-forcing directories, since you need to know how to tell a “real” 404 apart from a custom one.
curl -i https://target.tld/some-random-nonexistent-path
-i includes response headers so you can see the Server: header and status code together in one shot, rather than needing a separate request for headers aloneWhat to look for:
404, but sometimes sites softly 200 a “not found” page instead — worth noting either way)Server: or X-Powered-By: headers, which can reveal the exact web server software and versionConcept: security.txt (formalized as RFC 9116) is the standardized, machine-readable location where organizations publish how researchers should responsibly disclose vulnerabilities — contact emails, PGP keys, disclosure policy links, sometimes even a scope of what’s in-bounds for testing.
curl https://target.tld/.well-known/security.txt
Note: The .well-known/ directory itself is a broader standard (RFC 8615) used to store various “important info for automated systems” files beyond just security.txt — worth remembering as a pattern, since other .well-known/ files can be worth checking too (e.g. .well-known/change-password, .well-known/openid-configuration).
Some older or non-compliant sites put security.txt at the site root instead of under /.well-known/, so check both:
curl https://target.tld/security.txt
curl https://target.tld/.well-known/security.txt
Concept: If a web server has directory indexing enabled (common misconfiguration, especially on Apache) and there’s no index.html/index.php file present in that directory, the server falls back to rendering a raw file listing instead — showing you every file in that directory, including ones never linked anywhere on the site itself.
Step 1: find candidate directories. Check the page source / view-source of the main site for any referenced paths (image directories, upload folders, asset paths):
curl -s https://target.tld/ | grep -oE 'href="[^"]*"|src="[^"]*"'
This pulls out every linked path/resource referenced in the HTML — a quick way to spot directory names worth checking directly (e.g. spotting /images/logo.png tells you /images/ exists and is worth browsing directly).
Step 2: browse the directory path directly (with a trailing slash, no filename) and see if it renders a listing instead of a 404 or the homepage:
curl -i https://target.tld/images/
A 200 with an HTML file listing (you’ll see literal <a href="..."> tags for every file, often in a plain table layout) — versus a 403 Forbidden or 404 Not Found — is the tell that indexing is enabled here.
What to look for once you find a listing: files that were clearly never meant to be public — old backups, .git/ folders, config files, or anything with a name suggesting it was left behind by a developer rather than deliberately published.
Concept: Web applications almost always ship with a predictable management/login path, and developers rarely rename it from the framework or CMS default. Manually guessing a shortlist of the most common ones is often faster than firing up a full brute-force tool.
curl -i https://target.tld/admin/
Common ones worth trying manually, roughly in order of likelihood:
/admin/
/administrator/
/manager/
/management/
/login/
/wp-admin/ (WordPress)
/wp-login.php (WordPress)
/user/login (Drupal)
/admin/login
/cpanel/
/phpmyadmin/
Reading the response: a 200 or a 30x redirect (often to a login page) confirms the path exists. A 403 Forbidden also confirms existence — the path is there, just access-restricted, which is still useful information (it tells you something is guarding that path, worth investigating further). A 404 means it’s genuinely not there under that name.
Concept: Automate the guessing from Recon 04 using a full wordlist instead of a handful of manual guesses — this is the same idea scaled up with a tool doing hundreds or thousands of requests instead of you typing them one at a time.
ls /usr/share/wordlists/dirb/
cat /usr/share/wordlists/dirb/common.txt
dirb’s bundled wordlists (common.txt, small.txt, big.txt) are a reasonable default starting point — small.txt for a quick pass, big.txt when you want to be thorough and have time to spare.
ffuf -w /usr/share/wordlists/dirb/common.txt -u https://target.tld/FUZZ
-w — wordlist-u — target URL, FUZZ marks the injection point-mc 200,301,302,403 to only show interesting status codes-fc 404 to filter out noisewfuzz -c -z file,/usr/share/wordlists/dirb/common.txt --hc 404 https://target.tld/FUZZ
-c — colorize output-z file,<path> — payload source is a file--hc 404 — hide responses with a 404 status code (noise reduction)gobuster dir -u https://target.tld -w /usr/share/wordlists/dirb/common.txt
dirb http://target.tld /usr/share/wordlists/dirb/small.txt
dirb is slower than ffuf/gobuster but requires zero setup — a reasonable first tool to reach for since it’s likely already installed.
dirbuster
In the GUI: enter target URL → load wordlist → uncheck “Recursive” for a flat top-level scan → Start.
Reading results: 200 = exists and accessible, 403 = exists but forbidden (still worth noting), 404 = doesn’t exist, 30x = redirect (often to a login page, still confirms existence).
Concept: A single physical server often hosts multiple websites/applications, and it decides which one to serve based on the Host header the client sends — this is “virtual hosting” (vhosts). Every server also has a default vhost: whatever it serves when the Host header doesn’t match any of its configured sites. That default is sometimes a totally different, less-locked-down application than the “real” site you were pointed at — it might be a default install page, an internal admin tool, or leftover test content.
Step 1: resolve the target to its IP, since you’ll be hitting the IP directly rather than the hostname:
dig +short target.tld
Step 2: request the IP directly with a bogus/random Host header:
curl -H "Host: some-random-string.com" http://TARGET_IP
-H "Host: ..." overrides what hostname the server thinks you’re asking for. Since some-random-string.com won’t match any of the server’s configured vhosts, it falls back to whatever it serves by defaultcurl https://target.tld shows you whether they differ — if they do, you’ve found the default vhost’s contentWhy this works conceptually: the DNS hostname and the actual server are two separate things — DNS just tells your client which IP to connect to; once connected, the Host header is what tells that server which site to serve. Bypassing DNS entirely (by hitting the IP) and supplying an arbitrary Host header exposes whatever’s “underneath” the routing logic.
Concept: Same idea as Recon 06, but over HTTPS — with the added wrinkle that the TLS certificate itself is tied to a specific hostname (via SNI, Server Name Indication), so requesting the IP directly or supplying a mismatched Host header means the certificate presented won’t validate against what you asked for.
curl -H "Host: some-random-string.com" https://TARGET_IP -v -k
-v — verbose, shows the full TLS handshake and both request/response headers (useful for learning what’s actually happening at the protocol level, not just the final response body)-k (or --insecure) — ignore cert validation errors, since the certificate won’t match your fake Host header or a bare IP anywayReading the verbose output: -v will show you the certificate details during the handshake (issuer, the hostname(s) it’s actually valid for) even before you see the response body — useful context on its own, separate from whatever default-vhost content comes back.
Related check: this is also a natural moment to verify what TLS versions/ciphers a server accepts, since -v surfaces the negotiated TLS version directly in the handshake output — older accepted versions (TLS 1.0/1.1) can themselves be a finding worth noting.
Concept: A single TLS certificate can be issued to cover multiple hostnames at once (its Subject Alternative Names, or SANs) — a common practice so one cert can serve target.tld, www.target.tld, api.target.tld, etc. Reading the cert directly can reveal every hostname it was issued for, including ones that were never linked anywhere on the public-facing site.
openssl s_client -connect target.tld:443 -servername target.tld < /dev/null 2>/dev/null | openssl x509 -noout -text
-servername — sets SNI so the server presents the correct cert for that hostname (many servers host multiple certs and pick based on SNI)< /dev/null — closes stdin immediately so s_client doesn’t hang waiting for interactive inputopenssl x509 -noout -text to parse and pretty-print the certificate details instead of the raw handshake outputLook for the X509v3 Subject Alternative Name: section — it lists every hostname the cert covers, comma-separated.
echo | openssl s_client -connect target.tld:443 -servername target.tld 2>/dev/null | openssl x509 -noout -ext subjectAltName
Safari/Chrome dev tools → Security tab, or click the padlock/site info icon (see notes on viewing certs without a padlock — Web Inspector’s Security tab, or an external tool like SSL Labs’ SSL Server Test, both work too and often present the SAN list more readably than raw openssl output).
What to do with a new hostname found here: treat it exactly like a newly discovered subdomain — re-run Recon 00–07’s checks against it (robots.txt, directory brute-force, default vhost check) since it’s effectively a brand-new target you didn’t know about a moment ago.
Concept: HTTP response headers carry a lot of metadata beyond just the page content, and checking them explicitly (rather than just glancing at a rendered page) is a habit worth building for every request during recon — not just the ones where something looks obviously wrong.
curl -i http://target.tld/
-i — include response headers in the output, prepended above the bodyWhat to look for, systematically:
Server: — web server software/versionX-Powered-By: — backend framework/language, if the server chooses to disclose itstaging/X-Environment: dev)Set-Cookie: — cookie names/flags can hint at the backend framework (e.g. PHPSESSID, JSESSIONID, laravel_session) even without any other clueStrict-Transport-Security, Content-Security-Policy, X-Frame-Options — not directly a “find the flag” thing, but useful context on how security-conscious the target’s setup is overallIf headers alone don’t show what you need, try requesting with -v instead of -i to also see the request headers curl sent, useful for comparing what you sent vs. what came back.
Concept: When a target exposes a large or programmatically-generated set of subdomains/paths (for example, a whole family of hosts named like 0x[hex-digit].a.target.tld), checking each one by hand in a browser doesn’t scale — the practical approach is to script the enumeration and pull down a specific visual asset (like a logo image) from each one, then eyeball the results side by side to spot the one that’s different.
Step 1: script the list of hosts to check, if they follow a predictable pattern:
for i in $(seq 0 15); do
hex=$(printf '%x' $i)
echo "0x${hex}.a.target.tld"
done
This generates every hostname in the pattern (0x0.a.target.tld through 0xf.a.target.tld, for a hex-digit-based naming scheme) without typing each one manually.
Step 2: download the visual asset from each host.
for i in $(seq 0 15); do
hex=$(printf '%x' $i)
wget "https://0x${hex}.a.target.tld/logo.png" -O "logo_0x${hex}.png"
done
wget ... -O filename — saves each downloaded image under a distinct filename so you can tell them apart afterwardStep 3: review the images — open each file directly, or batch them into an image viewer/gallery to flip through quickly. The goal is spotting the one image that’s visually different from all the others (a different color, an extra element, text overlaid on it — whatever the “odd one out” signal is for that specific exercise).
General pattern: any time recon turns up a large family of similarly-named hosts rather than one-off subdomains, scripting the enumeration (a bash loop, or a quick Python script) beats manual browsing every time — the pattern is: generate the list → hit every host the same way → diff/compare the results to find the one that stands out. For larger host lists than a manual visual scan can handle, screenshot-automation tools like aquatone or gowitness scale this same idea up further:
cat hosts.txt | aquatone -ports 80,443
gowitness scan file -f hosts.txt
Concept: Finding subdomains you weren’t given directly — via brute force, zone transfers, or passive sources.
dig target.tld # default A record lookup
dig A target.tld
dig target.tld NS # nameservers
dig target.tld TXT # TXT records
dig target.tld MX # mail servers
dig -x 1.2.3.4 # reverse lookup
dig @ns1.target.tld target.tld # query a specific nameserver
dig axfr @ns1.target.tld target.tld
axfr requests a full zone transfer — if the nameserver is misconfigured to allow this to anyone, it dumps every record in the zone in one shot. Try this against every nameserver returned by dig target.tld NS.
dnsrecon -d target.tld -D /usr/share/wordlists/dnsmap.txt -t brt
-d — domain-D — wordlist of subdomain names to try-t brt — brute-force modednsenum --dnsserver <DNS_IP> --enum -p 0 -s 0 -o subdomains.txt -f /usr/share/wordlists/dnsmap.txt target.tld
for sub in $(cat wordlist.txt); do
dig $sub.target.tld | grep -v ';\|SOA' | sed -r '/^\s*$/d' | grep $sub
done
target.tld and pull every hostname that’s ever had a cert issuedcurl -s "https://crt.sh/?q=%25.target.tld&output=json" — quick CLI pull of crt.sh dataConcept: A single hostname can round-robin (or otherwise route) across several different backend servers. Each backend may be running different code, a different version, or have different quirks/vulnerabilities — but a single request only ever shows you one of them. Repeating the same request enough times can surface a different backend’s response.
curl balancer.hackycorp.com
Just keep re-running it:
for i in $(seq 1 20); do curl -s balancer.hackycorp.com; echo; done
or as a one-liner loop directly in the shell:
while true; do curl -s balancer.hackycorp.com; echo; sleep 0.2; done
(Ctrl+C to stop once you spot a different response.)
What to look for: Any response that differs from the baseline (<h1>Not yet</h1> in this case) — different HTML, different headers, different status code, different timing. That different response is a signal you’ve hit a different backend server, which may expose different content, a debug endpoint, or a vulnerability that isn’t present on the “default” backend.
Why this matters in the wild: load-balanced infrastructure often has backends at different patch levels or configs (a canary/staging box mixed in with production, a backend mid-deploy, etc.). Repeated requests are a cheap way to fingerprint that inconsistency before targeting it further.
Useful headers to diff between responses, once you’re comparing outputs:
curl -sI balancer.hackycorp.com
Compare Server, Date, X-Powered-By, or any custom headers across multiple runs — sometimes the body looks identical but the headers give away a different backend.
Concept: TXT records are free-form text fields in DNS, commonly used for domain ownership verification (SPF, DKIM, site verification codes) or to stash config/service info. Worth checking on any subdomain you find, since they sometimes leak more than the owner intended.
dig key.z.hackycorp.com
Basic lookup first — confirms the host resolves and shows its default record type (usually A).
dig -t TXT key.z.hackycorp.com
-t TXT — explicitly request the TXT record type instead of the default A recordWhat to look for: the ANSWER SECTION of the output — that’s where the actual TXT record content shows up, quoted in the response.
General pattern: any time you want a specific DNS record type instead of the default, use -t <TYPE>:
dig -t A domain.tld
dig -t MX domain.tld
dig -t NS domain.tld
dig -t TXT domain.tld
dig -t CNAME domain.tld
Concept: Zone transfers exist so DNS servers can sync records with each other, but they’re only supposed to be allowed for trusted secondary nameservers. If a server is misconfigured to allow AXFR from anyone, it’ll hand over its entire zone — every record it holds for that domain — in one request.
dig AXFR z.hackycorp.com
Run bare like this, it’ll almost always fail — by default dig queries your resolver/the domain’s general nameservers, not necessarily the actual authoritative server, and most servers refuse AXFR from random sources anyway.
Step 1: find the authoritative nameserver.
dig -t SOA z.hackycorp.com
-t SOA — Start of Authority record, which names the primary authoritative nameserver for the zoneStep 2: retry AXFR against that specific nameserver.
dig AXFR z.hackycorp.com @z.hackycorp.com
@z.hackycorp.com — tells dig to query this specific server directly instead of your default resolverWhat to look for: if the transfer succeeds, the output dumps every record in the zone (A, TXT, CNAME, NS, MX, etc.) — including subdomains that were never publicly linked anywhere.
General pattern: always check the SOA/NS records first, then attempt AXFR against each nameserver individually — a zone can have multiple nameservers, and only one might be misconfigured to allow it.
dig NS z.hackycorp.com # list all nameservers for the zone
dig AXFR z.hackycorp.com @ns1.z.hackycorp.com
dig AXFR z.hackycorp.com @ns2.z.hackycorp.com
Concept: A nameserver can be authoritative (or at least willing to answer) for more than one zone — including internal/private zones that were never meant to be queried from outside. If you already know a working nameserver from a previous step, it’s worth asking that same server about other zone names too, not just the one you found it through.
Step 1: confirm the nameserver you already have.
dig -t NS z.hackycorp.com
This is the server you’ll target directly — no need to look up a separate SOA for the internal zone, since you’re leveraging a nameserver you already know answers AXFR requests.
Step 2: attempt AXFR on the internal zone name directly.
dig AXFR int @z.hackycorp.com
int — the zone name being queried. Note it’s not int.hackycorp.com, just the bare zone name int — internal zones are often named independently of the public domain, since they’re not meant to be resolvable from the public internet at all@z.hackycorp.com — the nameserver being asked, i.e. the one already confirmed to serve zones for this infrastructureWhat to look for: if the server is misconfigured to answer for internal zones as well as public ones, this dumps the internal zone’s full record set — hostnames, IPs, and services that were never meant to be externally visible.
General pattern: once you find any nameserver willing to do a zone transfer, don’t stop at the zone you found it through — try common/guessable internal zone names against it too (int, internal, corp, local, intranet, dev, staging):
dig AXFR internal @known-nameserver.tld
dig AXFR corp @known-nameserver.tld
dig AXFR local @known-nameserver.tld
Concept: BIND is one of the most widely used DNS server implementations, and by default it responds to a special query asking for its own version string. This is a classic DNS fingerprinting trick — knowing the exact version can point you toward known CVEs for that release.
dig +short -c chaos -t txt version.bind @NAMESERVER_IP
+short — trims the output down to just the answer, no headers/metadata-c chaos — queries the CHAOS class instead of the normal IN (internet) class. Version/debug info is served under this special class rather than as a normal DNS record-t txt — the version string comes back as a TXT recordversion.bind — the special reserved query name BIND recognizes as “tell me your version”@NAMESERVER_IP — target the actual nameserver directly (the IP, not just the domain name) since this is a server-specific quirk, not a zone recordWhat to look for: the output is literally the BIND version string (e.g. 9.16.1), which you can then cross-reference against known vulnerabilities for that release.
General pattern: CHAOS-class queries are a good “does this reveal too much” check on any DNS server you find during recon — worth trying by default alongside SOA/NS/AXFR checks:
dig +short -c chaos -t txt version.bind @NAMESERVER_IP
dig +short -c chaos -t txt hostname.bind @NAMESERVER_IP # sometimes reveals the server's hostname too
Concept: Organizations often have a public GitHub presence, and even throwaway/test repos can leak real names, emails, or usernames of the people who work there. This one’s less about tooling and more about manual OSINT technique — knowing where to look on GitHub itself.
Step 1: find the org’s GitHub account.
Google: "hackycorp github"
or search directly on GitHub:
https://github.com/search?q=hackycorp&type=organizations
Step 2: browse the org’s repositories until you find the one named (in this case test1).
Step 3: find the committer.
General pattern: this same approach — org → repo list → commit history → author — is a repeatable technique any time you’re doing recon on a company’s GitHub presence:
Insights → Contributors on a repo for a full list of everyone who’s committedtest, test1, poc, demo, backup) are worth checking first — they’re less likely to have been cleaned up before being made publicConcept: GitHub organizations list their People (members) publicly by default, and each of those individuals often has their own personal repos alongside anything under the org itself. A developer’s personal projects are usually far less curated than official company repos — old test scripts, personal notes, forgotten API keys/configs, side projects that reference internal tooling — so once you know who works there (from a step like Recon 17), checking what they’ve personally pushed is a natural next move.
Step 1: find the org’s People tab.
https://github.com/orgs/hackycorp/people
or from the org’s main page, click the People tab.
Step 2: click into an individual member’s profile, which shows their own public repositories separate from the org’s.
Step 3: browse their personal repos the same way you’d browse an org’s — look for anything oddly named, clearly a test/scratch project, or something that references the company’s real infrastructure.
General pattern: this is the natural follow-up to Recon 17 — once a name/username surfaces from a commit, always check:
github.com/<username> — their full personal repo listgithub.com/<username>?tab=repositories&type=source — filters out forks, showing only repos they actually createdgithub.com/<username>/<username> repo, if they made one) — people sometimes list current employer, email, or other contact info there directlyConcept: Git records an author name + email on every single commit, and that email is whatever was set in the committer’s local git config at the time — not necessarily their work email. Developers frequently commit from a personal machine with a personal email still configured, which leaks personal accounts or hints at internal systems that differ from their usual company address.
Step 1: clone the repo.
git clone https://github.com/hackycorp/repo7.git
cd repo7
ls
Step 2: check the commit history for author emails.
git log
This shows every commit with its Author: Name <email> line. Scroll through looking for one email that doesn’t match the pattern of the others (different domain, personal-looking address like Gmail, or a naming convention that doesn’t match the rest).
Faster ways to isolate just the emails, once you know what you’re looking for:
git log --format='%ae' # just author emails, one per commit
git log --format='%ae' | sort -u # unique list, easier to spot the odd one out
%ae — author email format specifier for git logsort -u — dedupes the list so repeated commits by the same person only show onceWhat to look for: most commits will share a consistent company-domain email pattern (e.g. name@hackycorp.com). The one that stands out — a different domain, a personal provider, or an inconsistent naming style — is the target.
General pattern: this technique applies to any git repo you have access to, public or otherwise — always worth a quick git log --format='%ae' | sort -u pass on a new repo during recon, since it’s a cheap way to enumerate every identity that’s ever touched the code.
Concept: The default branch (main/master) is only ever part of the story. Developers often push experimental work, old configs, or debug/test code to other branches and never clean them up — and those branches stay fully accessible even if they’re not linked anywhere obvious in the UI.
Step 1: clone the repo (grabs all branches by default).
git clone https://github.com/hackycorp/repo3.git
cd repo3
Step 2: list every branch, including remote ones.
git branch -a
-a — show both local and remote-tracking branches. A fresh clone only checks out the default branch locally, but the remote branches are already known to git even though you haven’t switched to them yetStep 3: switch to and inspect each branch.
git checkout branch-name
ls
git log
Repeat for every branch listed — check file contents, commit history, and commit messages on each, the same way you would on the main branch.
Faster way to browse without switching back and forth:
git log --all --oneline
--all — shows commits across every branch at once, not just the current one--oneline — condensed one-line-per-commit view, useful for scanning quickly for suspicious commit messages before drilling into a specific oneWhat to look for: branches with unusual names (dev, test, backup, old, a developer’s first name, a ticket number) are worth prioritizing — these are exactly the kind of branches that get forgotten about and never merged or deleted.
General pattern: always run git branch -a (or check the branch dropdown on GitHub’s web UI) as a standard step right after cloning any repo during recon — never assume the default branch is the only one worth reading.
Concept: Same starting point as Recon 20 (enumerate every branch), but the goal here is to actually read the code/file contents on each branch rather than just noting the branch exists — sensitive info tends to be buried inside files, not in branch names or commit messages alone.
Step 1: clone and list branches, as before.
git clone https://github.com/hackycorp/repo4.git
cd repo4
git branch -a
Step 2: switch to each branch and actually look through the files.
git checkout branch-name
ls -la
Then open/inspect anything that looks relevant:
cat filename
find . -type f -name "*.env"
find . -type f -name "*.config*"
Step 3: search file contents directly for likely secrets, across the whole branch.
grep -r "password" .
grep -r "key" .
grep -ri "secret\|token\|api_key" .
-r — recursive, search every file in the current directory downward-i — case-insensitive, catches Secret, SECRET, secret all at onceStep 4: diff a suspicious branch against main to see exactly what changed.
git diff main branch-name
This shows line-by-line differences, which is often faster than manually reading every file if the branch is large — you only see what’s actually different from the default branch.
Step 5: check history within a branch, not just its current state.
git log -p branch-name
-p — shows the actual patch/diff for every commit, not just the commit message. A file can be deleted later in a branch’s history but still be fully recoverable from an earlier commit.What to look for: hardcoded credentials, internal hostnames/IPs, API keys, .env files, config files with connection strings — anything that reads like it wasn’t meant to be committed.
General pattern: treat every branch like its own mini recon target — checkout, ls, grep -r, and git log -p are the four commands worth running every time, not just git branch -a and moving on.
Concept: Deleting a file and committing that deletion does not remove it from the repo’s history — it’s still fully present in every commit before the deletion. A common mistake pattern is: commit a secret → realize it → delete the file → commit again, thinking it’s “gone.” It isn’t. This is the direct follow-up to the git log -p habit from Recon 21.
Approach used: git log -p
git clone https://github.com/hackycorp/repo9.git
cd repo9
git log -p
Scroll through the patch output looking for a diff that only removes lines — in diff format, removed lines are prefixed with -. A commit whose diff is entirely - lines (nothing added) is a deletion commit, and scrolling to it shows you exactly what content was removed.
Faster ways to jump straight to deletions, without scrolling through everything:
git log --diff-filter=D --summary
--diff-filter=D — only show commits that Deleted files--summary — includes a short summary of what was deleted in each matching commitOnce you’ve found the commit that deleted the file, view the file as it existed right before that deletion:
git show <commit-hash>~1:path/to/deleted/file
<commit-hash>~1 — the parent commit (one before the deletion):path/to/deleted/file — the file path as it existed in that commitOr restore it entirely into your working directory:
git checkout <commit-hash>~1 -- path/to/deleted/file
Note on tig: the instructor’s video used tig, a text-based UI for browsing git repos interactively (like a terminal git log browser). It’s a separate package, not built into git itself:
apt-get install tig
If it’s not installed and you don’t want to bother, plain git log -p (or the --diff-filter=D approach above) gets you the same information — just less visually browsable.
Concept: Commit messages themselves can leak sensitive information, separate from anything in the actual file changes — a developer might write “removed hardcoded key: XXXX” or “fixed password issue” directly in the message. When a repo has a lot of commits, scrolling through them all is slow, so searching by keyword is far more efficient.
Step 1: clone the repo.
git clone https://github.com/hackycorp/repo0a.git
cd repo0a
Step 2: browse everything, if the repo is small enough.
git log --all
--all — shows commits across every branch, not just the current one, since sensitive commit messages aren’t guaranteed to be on the default branchStep 3: search commit messages by keyword instead of scrolling manually.
git log --all --grep="key"
--grep="term" — filters commits to only those whose commit message contains the given term. Swap "key" for whatever term makes sense (password, secret, token, fix, oops, remove).Useful variations:
git log --all --grep="key" -i # case-insensitive search
git log --all --grep="key" --oneline # condensed one-line-per-commit view of matches
git log --all -i --grep="password" --grep="secret" --all-match # match commits containing BOTH terms
Note on tig: as in Recon 22, the instructor used tig (an interactive terminal UI for browsing git history) rather than raw git log. It’s not required — git log --all --grep= gets you the same result from the command line, just install tig (apt-get install tig) if you’d rather browse visually.
General pattern: --grep on commit messages is the natural companion to the git log -p content search from Recon 21/22 — one searches what changed, the other searches what the developer said about the change.
Concept: Sites often load JS/CSS from a separate subdomain (a CDN or dedicated asset host), and that asset server is a distinct target from the main site — it may have its own misconfigurations, and since it’s “just serving static files,” it doesn’t always get the same scrutiny as the main application.
Step 1: find the asset subdomain via the browser.
Open the page, use Inspect Element → Network tab (or just view page source) and look at where .js/.css files are actually being loaded from. In this case, that’s assets.hackycorp.com rather than the main domain.
Step 2: resolve it to see what’s actually hosting it.
dig assets.hackycorp.com
The response here pointed to a cloudfront.net hostname — meaning the asset server is sitting behind AWS CloudFront (a CDN), not hosted directly by the company’s own infrastructure.
Step 3: try requesting files directly off that asset host.
curl https://assets.hackycorp.com/key.txt
or just visit it in a browser. Since it’s a plain static-file host, there’s no application logic to stop you from requesting any filename directly — if the file exists and isn’t access-restricted, it’s served as-is.
General pattern: any time you spot assets loading from a separate subdomain, treat it as its own recon target:
dig it to see what’s actually behind it (own infra vs. CDN vs. third-party host)key.txt, robots.txt, .env, config.json, backup.zip) since static hosts rarely have directory protections beyond “does this exact file exist”Concept: S3 buckets have a legacy ACL grant called “Any Authenticated AWS Users” that many people historically misunderstood as “only people in my own AWS account” — it actually means any AWS account at all, not just the bucket owner’s. If a bucket/object has this grant, anyone with any AWS CLI credentials configured (their own account, unrelated to the bucket owner) can read the file.
Step 1: try a direct download with the AWS CLI.
aws s3 cp s3://assets.hackycorp.com/key2.txt key2.txt
If you get a HeadObject 403/Forbidden error — this is a known quirk: aws s3 cp runs a HeadObject check before downloading, and that metadata-check permission isn’t always granted even when actual read access is. Skip it by calling the lower-level API directly:
aws s3api get-object --bucket assets.hackycorp.com --key key2.txt key2.txt
If you then get an AccessDenied error on GetObject itself — this usually isn’t the bucket’s problem, it’s your own IAM user’s. A freshly created AWS CLI user typically has no permissions at all by default (AWS denies by default until a policy is attached), so even if the bucket allows any authenticated account in, your IAM user still needs explicit permission to make S3 calls in the first place.
Check who you’re authenticated as:
aws sts get-caller-identity
Check what policies are already attached:
aws iam list-attached-user-policies --user-name YOUR_USERNAME
If nothing’s attached, grant yourself basic S3 read access:
aws iam attach-user-policy \
--user-name YOUR_USERNAME \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
Then retry the download — aws s3 cp or aws s3api get-object will both work once your own IAM user actually has S3 permissions:
aws s3 cp s3://assets.hackycorp.com/key2.txt key2.txt
Other useful checks along the way:
aws configure get region # confirm region isn't blank/"none"
aws configure set region us-east-1 # set explicitly if needed
aws s3api get-bucket-location --bucket assets.hackycorp.com # find the bucket's actual region
Key takeaway: cp and get-object both hit the same underlying GetObject API — swapping between them doesn’t bypass an actual AccessDenied. If GetObject itself is denied, the fix is almost always on your own IAM user’s permissions, not the target bucket’s.
Concept: Anything shipped to the browser as client-side JS is fully visible to the end user, no matter how it’s minified or obfuscated. Developers sometimes hardcode API keys, tokens, or internal endpoints directly into JS thinking it’s “just for the frontend to work” — but since the browser has to download and execute the raw file, so does anyone else.
Step 1: open dev tools and browse the loaded JS files.
JS) is another way to see the same list, showing each request as it happensStep 2: read through the files directly.
For a large/minified file, browser dev tools usually offer a “pretty print” / {} button in the Sources panel to reformat minified JS into readable multi-line code — much easier to visually scan for anything key-shaped.
Step 3: search for likely keywords within the file (most dev tools support Ctrl+F / Cmd+F search within an open file):
key
token
secret
api_key
password
Authorization
Command-line alternative, if you’d rather grep than click through dev tools:
curl -s https://assets.hackycorp.com/somefile.js | grep -i "key\|token\|secret"
or save it locally and search:
curl -s https://assets.hackycorp.com/somefile.js -o somefile.js
grep -iE "key|token|secret|password" somefile.js
General pattern: any time you’re recon’ing a site, checking loaded JS files for hardcoded secrets is worth doing as a standard step alongside checking the asset host itself (Recon 24) — the two often go hand in hand, since discovering the asset subdomain is usually how you find the JS files to inspect in the first place.
| Tool | Purpose | Core command |
|---|---|---|
curl |
HTTP requests, header manipulation | curl -i -H "Host: X" URL |
openssl s_client |
Inspect TLS certs directly | openssl s_client -connect host:443 |
dig |
DNS queries | dig DOMAIN TYPE |
wfuzz |
Web fuzzing (dirs, params, vhosts) | wfuzz -z file,LIST URL/FUZZ |
ffuf |
Faster modern fuzzer | ffuf -w LIST -u URL/FUZZ |
gobuster |
Dir/DNS/vhost brute force | gobuster dir -u URL -w LIST |
dnsrecon / dnsenum |
DNS enumeration & zone transfers | dnsrecon -d DOMAIN -D LIST -t brt |
aquatone / gowitness |
Screenshot many hosts at once | cat hosts.txt \| aquatone |
Since I’ve been running these tools via Docker containers, a couple of reminders that keep biting:
latest — newer Python removes modules (like imp) that old tools rely on, but some C-extension dependencies (like pycurl) need Python 3.10+ to compile.docker run -it python:3.11 /bin/bash as the default base for older tools rather than plain python:latest.git clone https://... not git://... — the old protocol is deprecated/often blocked.Compiled from public write-ups (Medium: “Comprehensive Guide to Mastering Reconnaissance… on PentesterLab” by Shamil Abbas; HackTricks DNS pentesting reference; Hackviser DNS pentesting guide) plus standard tool documentation and my own experience with these exercises. Exercise-specific flags/keys intentionally omitted — this is meant as a command reference, not a solutions sheet.
tags: pentesterlab - recon