Step 315. Real-Service Recon — Attack Surface Mapping

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 requests package, this chapter’s mock target app.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.

  1. Collect — scrape up subdomain candidates with subfinder, amass, etc.
  2. Liveness check — keep only the hosts that actually respond (httpx’s role)
  3. Classify — identify each host’s tech stack and character (fingerprinting)
  4. 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

  1. Start the lab from 3-1 and run 3-2’s recon.py, saving the output
  2. Add one endpoint to make_dev() in app.py (e.g., /backup), reflect it in the word list too, and confirm the recon finds the new asset
  3. Complete attack_surface_map.md in the 3-4 format — live host table, hidden paths, priority candidates, and the scope-verification box, all included
  4. 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.py in 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.md and 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 domainsort -uhttpx 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.