Step 315. Real-Service Recon — Attack Surface Mapping
Level 4 — Bug Bounty | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: you have finished Step 171 (subdomain enumeration) and Step 314 (scope and the operations brief). You can use Python
requests.
- What you need: Python 3, the
requestspackage, this chapter’s mock targetapp.py(Flask). - ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Caution: recon is legal only inside scope too. In this chapter, actual commands run only against the local mock target (127.0.0.1), and every real-service result is a screen example.
Bug bounties are won by finding assets nobody else is looking at. A company’s real attack surface is far wider than the main site — dev servers, staging environments, old APIs, forgotten admin pages. Today you collect and classify these assets to build an "attack surface map." The principles are what you learned in Step 171; today you run that whole chain in real-world order.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Execute the 4 stages of the recon chain —
collect → liveness check → classify → map— in order - Explain the pipeline that merges subfinder/amass output and removes duplicates
- Reproduce httpx’s role (status code · title · tech stack check) in Python
- Extract hidden endpoints from JavaScript files
- Complete an attack surface map document and verify that everything collected is in scope
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3, Flask (mock target), requests |
| Today’s tools | (hands-on) dictionary-based enumeration + live probe + JS path extraction scripts, (concept intro) subfinder · amass · httpx · Wappalyzer |
| Concepts needed | Passive/active enumeration (Step 171), attack surface, fingerprinting, scope (Step 314) |
| Today’s deliverable | 1 attack_surface_map.md — live hosts + tech stacks + priority candidates |
2-1. The 4 Stages of the Recon Chain
Real-world recon follows a fixed pipeline.
- Collect — scrape up subdomain candidates with subfinder, amass, etc.
- Liveness check — keep only the hosts that actually respond (httpx’s role)
- Classify — identify each host’s tech stack and character (fingerprinting)
- Map — turn it into a document marking priority exploration candidates
Every stage of this chain runs only against in-scope domains. If an out-of-scope name sneaks into the collection, delete it from the map.
2-2. The Collection Tools — subfinder and amass
The subfinder you saw in Step 171 combines dozens of public sources (certificate logs, search engines, DNS collection services) to produce subdomains. amass does similar work with a different source mix, so using both fills each other’s gaps. That’s why the real-world standard is merge → dedupe.
# Screen example — run against a real service only after confirming scope
subfinder -d target.com -o subs1.txt
amass enum -passive -d target.com -o subs2.txt
cat subs1.txt subs2.txt | sort -u > subs.txt
2-3. Liveness Check and Fingerprinting — httpx’s Role
More than half of a collected list is dead names. httpx sends an HTTP request to each name, keeps only the responders, and prints status code, page title, and server info along the way. That one line of output is the raw material for "where do I look first."
Fingerprinting is working out the tech stack from response headers and page content — headers like X-Powered-By: PHP/5.6, the CMS/framework info Wappalyzer catches. An old tech stack is immediately a candidate for "an unpatched asset."
2-4. JavaScript — The Hidden Map
Frontend JS files are a collection of endpoint sightings. A single fetch('/api/internal/...') line a developer forgot to remove often leads to an undocumented API. Downloading JS and extracting /api/... paths with a regex — the technique you practice today — is a bug hunter’s basic skill.
2-5. The "Not the Main Site" Principle
When the collection runs to hundreds of names, you freeze on where to start. The filtering principle is one — not the main site. Hosts named dev, staging, old, test, api, backup are the priority candidates. Every hunter in the world has already swept the main site, but forgotten dev servers still have vulnerabilities left.
3. Follow Along
3-1. Starting the Mock Target
Build the local target lab.local on which you’ll run the real-world tool chain as-is. Four virtual hosts are simulated by ports — www (5001), dev (5002), api (5003), old (5004). app.py:
import threading, time
from flask import Flask, request, jsonify, session, Response
USERS = {
"alice": {"id": 1001, "pw": "alice-pass!", "email": "alice@lab.local",
"phone": "010-****-1001", "addr": "Gangnam-gu, Seoul (fictional)"},
"bob": {"id": 1002, "pw": "bob-pass!", "email": "bob@lab.local",
"phone": "010-****-1002", "addr": "Haeundae-gu, Busan (fictional)"},
}
def make_www():
app = Flask("www"); app.secret_key = "www-secret"
@app.route("/")
def index():
return ("<html><head><title>LabShop - All Things Shopping</title></head>"
"<body><h1>LabShop</h1></body></html>")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return "<html><head><title>Login - LabShop</title></head><body></body></html>"
u, p = request.form.get("user",""), request.form.get("pw","")
if u in USERS and USERS[u]["pw"] == p:
session["user"] = u; return f"Login successful: {u}"
return "Login failed", 401
@app.route("/search")
def search():
q = request.args.get("q", "")
return f"<html><head><title>Search Results</title></head><body>'{q}': 0 results</body></html>"
return app
def make_dev():
app = Flask("dev")
@app.route("/")
def index():
r = Response("<html><head><title>LabShop Dev Server</title></head>"
"<body><h1>DEV</h1><script src='/static/app.js'></script>"
"</body></html>")
r.headers["X-Environment"] = "development"; return r
@app.route("/static/app.js")
def js():
return Response(
"// dev buildnfetch('/api/v1/health');n"
"fetch('/api/internal/metrics');n"
"fetch('/api/v2/admin/export?fmt=csv');n",
mimetype="application/javascript")
@app.route("/admin")
def admin():
return "<html><head><title>Admin Console (DEV)</title></head><body>Admin</body></html>"
return app
def make_api():
app = Flask("api"); app.secret_key = "api-secret"
@app.route("/api/v1/health")
def health():
return jsonify({"status": "ok", "version": "1.4.2"})
return app
def make_old():
app = Flask("old")
@app.route("/")
def index():
r = Response("<html><head><title>Old Shop (2019)</title></head><body></body></html>")
r.headers["X-Powered-By"] = "PHP/5.6.40"; return r
return app
SERVICES = [(make_www, 5001, "www.lab.local"), (make_dev, 5002, "dev.lab.local"),
(make_api, 5003, "api.lab.local"), (make_old, 5004, "old.lab.local")]
if __name__ == "__main__":
from werkzeug.serving import make_server
for factory, port, name in SERVICES:
srv = make_server("127.0.0.1", port, factory())
threading.Thread(target=srv.serve_forever, daemon=True).start()
print(f"[UP] {name} -> http://127.0.0.1:{port}")
print("Lab is up. Ctrl+C to stop.")
while True:
time.sleep(1)
Input (terminal 1):
python app.py
Output (measured 2026-09-09):
[UP] www.lab.local -> http://127.0.0.1:5001
[UP] dev.lab.local -> http://127.0.0.1:5002
[UP] api.lab.local -> http://127.0.0.1:5003
[UP] old.lab.local -> http://127.0.0.1:5004
Lab is up. Ctrl+C to stop.
How to read it: four "subdomains" are now alive on 127.0.0.1. What subfinder would find for you in the real world, we built by hand in the lab. Every command from here runs in terminal 2.
3-2. Running the Recon Chain — From Collection to Map
Make recon.py. The chain’s 4 stages are inside, in order.
import re, requests
# --- 1. Collect: dictionary-based enumeration (subfinder+amass's role in the real world) ---
zone = { # mock DNS — in reality this is what DNS answers
"www.lab.local": 5001, "mail.lab.local": None, "dev.lab.local": 5002,
"api.lab.local": 5003, "blog.lab.local": None, "old.lab.local": 5004,
"vpn.lab.local": None, "test.lab.local": None,
}
wordlist = ["www", "mail", "ftp", "dev", "api", "blog", "old", "vpn", "test", "shop"]
found = []
for w in wordlist:
name = f"{w}.lab.local"
if name in zone and zone[name]:
found.append((name, zone[name]))
print(f"[1] tried {len(wordlist)} words -> {len(found)} found")
for name, port in found:
print(f" {name:18s} -> 127.0.0.1:{port}")
# --- 2. Liveness check: live host probe (httpx's role) ---
print("n[2] Live host probe (status/title/tech hint)")
rows = []
for name, port in found:
try:
r = requests.get(f"http://127.0.0.1:{port}/", timeout=3)
m = re.search(r"<title>(.*?)</title>", r.text, re.S)
title = m.group(1) if m else "(none)"
tech = (r.headers.get("X-Powered-By") or r.headers.get("X-Environment")
or r.headers.get("Server", ""))
rows.append((name, port, r.status_code, title, tech))
print(f' http://127.0.0.1:{port} [{r.status_code}] "{title}" ({tech})')
except requests.ConnectionError:
print(f" {name}: connection failed")
# --- 3. Collect hidden endpoints from JS ---
print("n[3] Extracting API paths from dev.lab.local's app.js")
js = requests.get("http://127.0.0.1:5002/static/app.js", timeout=3).text
paths = sorted(set(re.findall(r"['"](/api/[^'"]+)['"]", js)))
for p in paths:
print(f" {p}")
print(f" -> {len(paths)} paths found")
# --- 4. Mark priority candidates (the "not the main site" principle) ---
print("n[4] Priority exploration candidates (name rule: dev/old/test/api)")
for name, port, code, title, tech in rows:
tag = "*" if re.match(r"(dev|old|test|staging|api).", name) else " "
print(f" [{tag}] {name:18s} {title}")
Input (terminal 2):
python recon.py
Output (measured 2026-09-09):
[1] tried 10 words -> 4 found
www.lab.local -> 127.0.0.1:5001
dev.lab.local -> 127.0.0.1:5002
api.lab.local -> 127.0.0.1:5003
old.lab.local -> 127.0.0.1:5004
[2] Live host probe (status/title/tech hint)
http://127.0.0.1:5001 [200] "LabShop - All Things Shopping" (Werkzeug/3.1.8 Python/3.12.14)
http://127.0.0.1:5002 [200] "LabShop Dev Server" (development)
http://127.0.0.1:5003 [404] "404 Not Found" (Werkzeug/3.1.8 Python/3.12.14)
http://127.0.0.1:5004 [200] "Old Shop (2019)" (PHP/5.6.40)
[3] Extracting API paths from dev.lab.local's app.js
/api/internal/metrics
/api/v1/health
/api/v2/admin/export?fmt=csv
-> 3 paths found
[4] Priority exploration candidates (name rule: dev/old/test/api)
[ ] www.lab.local LabShop - All Things Shopping
[*] dev.lab.local LabShop Dev Server
[*] api.lab.local 404 Not Found
[*] old.lab.local Old Shop (2019)
How to read it: look at [2] — api.lab.local‘s root path is 404, but this is not a dead host; it’s the normal look of an API-only host. Don’t discard on status code alone. In [3], paths not linked from any page, like /api/internal/metrics and /api/v2/admin/export, came out of the dev server’s JS — these are the "hidden endpoints." The [*] marks in [4] are the doors you’ll knock on starting tomorrow.
3-3. Comparing with Real-World Tool Output — Screen Example
Here’s what our script’s [2] looks like in the real world. Screen example (fabricated data):
Screen example (the shape of cat subs.txt | httpx -silent -title -tech-detect output):
https://www.example-corp.com [200] [Example Corp - Official]
https://dev.example-corp.com [200] [Dev Environment] [Werkzeug]
https://old-shop.example-corp.com [200] [Shop 2019] [PHP/5.6]
https://api.example-corp.com [404] []
How to read it: the format matches our [2] — address, status code, title, tech. Only the tool differs; the pipeline’s skeleton is identical to what you just built by hand. Actual runs happen only against targets whose scope you’ve confirmed.
3-4. Writing the Attack Surface Map
Turn the collection results into a document. attack_surface_map.md:
# Attack Surface Map — lab.local / Date: ____
### Live hosts
| Host | Status | Title | Tech hint | Priority |
|--------|------|--------|-----------|------|
| www.lab.local | 200 | LabShop main | Werkzeug/3.1.8 | |
| dev.lab.local | 200 | Dev Server | development environment marker | * |
| api.lab.local | 404 (root) | - | presumed API-only | * |
| old.lab.local | 200 | Old Shop | PHP/5.6.40 (old) | * |
### Hidden paths found in JS (dev.lab.local)
- /api/internal/metrics
- /api/v1/health
- /api/v2/admin/export?fmt=csv
### Scope verification
- All collection/probe targets are under lab.local (operations brief IN scope) — verified
How to read it: the "scope verification" box at the bottom is this document’s core. However good the map, if an out-of-scope asset is mixed in, the moment you follow that map it becomes illegal.
4. Missions & Exercises
Mission — Complete the Attack Surface Map
- Start the lab from 3-1 and run 3-2’s
recon.py, saving the output - Add one endpoint to
make_dev()inapp.py(e.g.,/backup), reflect it in the word list too, and confirm the recon finds the new asset - Complete
attack_surface_map.mdin the 3-4 format — live host table, hidden paths, priority candidates, and the scope-verification box, all included - Write one line of "why it’s a priority" for each priority candidate (e.g., "dev — development environment marker exposed, possible debug features")
Exercises
Exercise 1. Explain why you use subfinder and amass together and merge with sort -u.
Exercise 2. Give two grounds for judging that api.lab.local‘s root being 404 does not make it a "dead host."
Exercise 3. Explain from a development-process perspective why frontend JS files become "hidden maps."
Exercise 4. Explain why the "not the main site" principle holds, from the two perspectives of competition and maintenance state.
5. Model Answers & Completion Criteria
Mission Model Answer
After adding the endpoint, re-running recon.py should show the new host or path on the map. Examples of "priority reasons":
dev.lab.local — response header X-Environment: development. A dev server is open to the outside.
api.lab.local — the root 404 is normal. Need to check whether the /api/* paths found in JS actually answer.
old.lab.local — X-Powered-By: PHP/5.6.40. End-of-support version = likely a pile of known vulnerabilities.
How to verify: ① is the recon.py output saved? ② are the map document’s 4 boxes filled? ③ does each priority candidate have a one-line ground? ④ is the scope-verification box present? All ‘yes’ means complete.
Exercise Answers
Answer 1. The two tools have different source mixes, so each misses names the other finds. Merging raises the collection rate, and sort -u sorts the names and removes duplicates, making the input to the next stage (probing) clean. "Merge multiple sources and dedupe" is the standard pattern of recon collection.
Answer 2. First, a 404 means "the server is alive and answered" — a dead host fails to connect at all. Second, an API host normally has no browser-facing root page; its real functions live at paths like /api/v1/health. The criterion for liveness is not the status code but "did a response come back."
Answer 3. Developers write paths directly into JS when calling APIs from the frontend. Even when a feature is retired or made admin-only, the calling code in JS often isn’t removed, and internal paths slip out as-is through the build/deploy process. If the server configuration (access control) isn’t perfect, these paths become open doors as-is.
Answer 4. Competition perspective: the main site is where every hunter looks first, so easy vulnerabilities there are likely already reported (duplicates). Maintenance perspective: dev/staging/old assets outlive their purpose and get neglected, so patching and access control are often looser than on the main site. Vulnerabilities remain "where nobody looks and the admin has forgotten."
Completion Criteria Checklist
- [ ] I can state the recon chain’s 4 stages (collect → liveness check → classify → map) in order
- [ ] I ran
recon.pyin the local lab and probed 4 hosts - [ ] I verified hands-on that a 404 response does not mean "dead host"
- [ ] I extracted 3 hidden endpoints from JS
- [ ] I completed
attack_surface_map.mdand filled the scope-verification box - [ ] I can explain the correspondence between real-world tool (subfinder/httpx) output and my script’s output
6. Common Pitfalls & Fixes
Wall 1. ModuleNotFoundError: No module named 'requests'
Symptom:
ModuleNotFoundError: No module named 'requests'
Cause: the HTTP client package is missing.
Fix: run pip install requests. If Flask is also missing, you need pip install flask.
Wall 2. Everything fails with ConnectionRefusedError
Symptom:
requests.exceptions.ConnectionError: ... [Errno 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다
Cause: app.py (terminal 1) isn’t running, or has already exited.
Fix: check that python app.py in terminal 1 is alive and printing the 4 [UP] lines. On Korean Windows, a Korean error like the above may appear.
Wall 3. Too many results — no idea where to start
Symptom: the list runs to tens or hundreds, and you can’t lay a hand on it.
Cause: no filtering principle.
Fix: "not the main site" alone is enough — filter first by the dev/staging/old/test/api prefixes, then narrow again by old tech-stack headers. The star logic in [4] is that automation.
Wall 4. Out-of-scope names get mixed into the list
Symptom: unrelated domains (CDNs, third-party services, etc.) are mixed into the collection results.
Cause: public-source data has noise in it.
Fix: before probing, filter the whole list with Step 314’s scope_check.py and delete the out-of-scope entries. Getting mixed in at collection is common, but the moment a probe goes out it’s a rule violation.
Wall 5. Jumping straight to attacks without a map
Symptom: you start vulnerability testing the moment you find a host.
Cause: seeing recon not as "work to do" but as "work to skip."
Fix: complete the map document first. If you don’t know where you’ve looked and what remains, you keep circling the same places. The map is also your work checklist.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Recon chain | Collect → liveness check → classify → map, all inside scope |
| subfinder / amass | Public-source-based subdomain collection — different sources, use together |
| httpx | Narrowing to live hosts + status/title/tech-stack output |
| Fingerprinting | Working out the tech stack from response headers/page content (X-Powered-By, etc.) |
| JS endpoint collection | Extracting /api/... paths from frontend code — the hidden map |
| "Not the main site" | dev/staging/old first — no competition, no maintenance |
| 404 ≠ dead host | The fact a response came back is itself evidence of life |
Today’s Commands & Code
| Tool | What it does |
|---|---|
f"{word}.domain" + zone lookup |
Dictionary-based enumeration (Step 171 review) |
requests.get(url) + status/title/headers |
Python reproduction of httpx’s role |
re.findall(r"['"](/api/[^'"]+)['"]", js) |
Extract API paths from JS |
re.match(r"(dev|old|test|staging|api).", name) |
Priority-candidate selection rule |
(screen example) subfinder -d domain → sort -u → httpx |
The real-world collection pipeline |
The Core Instinct
Recon’s deliverable is not a "list" but a "map" — it becomes a map only when priority candidates carry stars and the scope-verification box is filled. And this map’s value lies in the corners nobody else drew. The 60-line script you just built is the exact skeleton of the subfinder+httpx pipeline — you now have eyes that can read real-world tool output. Next comes knocking on each of this map’s stars, one by one.
Once every box is checked, Step 315 is complete.