Step 316. Vulnerability Discovery — A Systematic Feature-by-Feature Approach

Step 316. Vulnerability Discovery — A Systematic Feature-by-Feature Approach

Level 4 — Bug Bounty | Difficulty ★★★★☆ | Estimated time: 3 hours 30 minutes

Prerequisites: you have finished Step 315 (attack surface map). You know the Level 2–3 web vulnerability concepts (IDOR, XSS, upload bypass).

  • What you need: Python 3, Step 315’s mock lab (app.py) + this chapter’s extension code, requests.
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
  • Caution: in real-world bug bounty, every test is performed only between test accounts you created yourself, and only inside the program’s scope and rules. The moment you access another user’s data, it’s intrusion.

A beginner bug hunter’s biggest misconception is "run a scanner and vulnerabilities come out." What scanners find is what everyone has already found. Valid bugs appear when you understand an application’s features and find their gaps — "if I change this number, do I see someone else’s data?", "does this cookie still work after logout?" Today you build a feature-by-feature checklist and measure its operation in the local lab.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain why "scanner dependence" is a beginner’s trap
  • Build a testing checklist by feature unit (auth · search · upload · API)
  • Execute the procedure for verifying IDOR with 2 test accounts (A/B)
  • Organize findings into a "candidate list" (feature · reproduction path · expected impact · verification status)
  • State the normal way to cope when discovery turns up nothing

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3, Flask mock lab, requests sessions
Today’s tools (hands-on) feature-by-feature checklist script hunt.py, (concept intro) Burp Suite proxy
Concepts needed IDOR, reflected XSS, session invalidation, extension bypass, authorization bypass (all Level 2–3 review)
Today’s deliverable 1 candidate_list.md — feature / reproduction path / expected impact / verification status

2-1. Why the Feature-by-Feature Approach

A real service is organized not by "vulnerability types" but by "features" — signup, login, search, upload, orders, API. A scanner finds only known patterns, but it can never find business logic gaps ("what if I skip the payment step?", "what if I change the order?"). The area with the fewest competitors is exactly this logic-bug territory.

So the procedure goes like this. ① First, use every feature like a normal user (in the real world, every request gets recorded through a Burp Suite proxy). ② For each feature, run a "vulnerabilities common to this feature" checklist. ③ Write findings into the candidate list.

2-2. The Feature-by-Feature Checklist

The minimal list we practice today. Each item is the real-world version of a vulnerability you learned in Levels 2–3.

Feature Test What to check
Auth/session Reuse the old cookie after logout Whether the session is invalidated server-side
Search/input Reflected XSS payload Whether output is unescaped
Upload Extension bypass, SVG Whether there’s only a blacklist
Admin page Direct request with a normal account Whether authorization is checked server-side
API Changing the numeric ID (IDOR) Whether ownership is verified
Password reset Issuing tokens in sequence Whether tokens are predictable

2-3. The Test-Account Principle

The absolute rule when running this list in the real world: all verification happens only between 2 accounts you created (A, B). Checking "is B’s data visible from A" and "opening some unknown third party’s data" are completely different — the latter is intrusion the moment it happens, and a violation of most programs’ rules.

Keep the PoC (proof of concept) minimal and stop immediately. "Scraping 100 people’s data to prove it" is not a report — it’s an incident.

2-4. The Candidate List — Discovery’s Deliverable

Discovery’s result is not vulnerabilities but a candidate list. Each entry needs four boxes — feature, reproduction path, expected impact, verification status (unverified/partial/confirmed). A "list of features confirmed clean" is also an accumulation of skill. Bug bounty is a probability game, so turning up nothing after days of discovery is normal.


3. Follow Along

3-1. Extending the Lab — Planting Features to Test

Add today’s test-target features to Step 315’s app.py. Three into make_www(), and replace make_api().

Add inside make_www():

    @app.route("/logout")
    def logout():
        session.pop("user", None)
        return "You have been logged out"

    @app.route("/upload", methods=["GET", "POST"])
    def upload():
        if request.method == "GET":
            return "<html><head><title>File Upload</title></head><body></body></html>"
        f = request.files.get("f")
        if not f:
            return "No file", 400
        name = f.filename or ""
        if name.lower().endswith(".php"):
            return "Extension not allowed", 403
        return f"Upload complete: {name} ({f.content_type})"

    @app.route("/profile")
    def profile():
        u = session.get("user")
        if not u:
            return "Login required", 401
        info = USERS[u]
        return jsonify({"user": u, "email": info["email"], "phone": info["phone"]})

Replace all of make_api() with:

PW_RESET_TOKENS = {}  # add near the top of the file

def make_api():
    app = Flask("api"); app.secret_key = "api-secret"
    @app.route("/api/v1/login", methods=["POST"])
    def login():
        u = request.json.get("user", "") if request.is_json else ""
        p = request.json.get("pw", "") if request.is_json else ""
        if u in USERS and USERS[u]["pw"] == p:
            session["user"] = u
            return jsonify({"ok": True, "user": u})
        return jsonify({"ok": False}), 401
    @app.route("/api/v1/health")
    def health():
        return jsonify({"status": "ok", "version": "1.4.2"})
    @app.route("/api/v1/users/<int:uid>")
    def get_user(uid):
        # vulnerability candidate: checks login only, no ownership verification
        if not session.get("user"):
            return jsonify({"error": "login required"}), 401
        for name, info in USERS.items():
            if info["id"] == uid:
                return jsonify({"id": uid, "user": name, "email": info["email"],
                                "phone": info["phone"], "addr": info["addr"]})
        return jsonify({"error": "not found"}), 404
    @app.route("/api/v1/password-reset", methods=["POST"])
    def reset():
        u = request.json.get("user", "") if request.is_json else ""
        if u not in USERS:
            return jsonify({"ok": False}), 404
        token = str(100000 + len(PW_RESET_TOKENS))  # vulnerability candidate: sequential numbers
        PW_RESET_TOKENS[u] = token
        return jsonify({"ok": True, "hint": "We sent a token by email"})
    return app

How to read it: vulnerability candidates are planted in the lab — finding where is today’s training. Start the lab with python app.py and proceed in terminal 2.

3-2. The Checklist Script — hunt.py

Transcribe 2-2’s table straight into code. Testing happens only between the lab accounts alice (A) and bob (B).

import requests

WWW, DEV, API = "http://127.0.0.1:5001", "http://127.0.0.1:5002", "http://127.0.0.1:5003"
XSS = "<img src=x onerror=alert(document.domain)>"

def line(t): print(f"n=== {t} ===")

line("T1. Auth — reusing the old session cookie after logout")
s = requests.Session()
r = s.post(f"{WWW}/login", data={"user": "alice", "pw": "alice-pass!"})
print("Login:", r.status_code, r.text.strip())
old_cookie = s.cookies.get("session")
s.get(f"{WWW}/logout")                      # perform logout
replay = requests.Session()
replay.cookies.set("session", old_cookie)   # reuse the old cookie as-is
r = replay.get(f"{WWW}/profile")
print(f"old cookie -> /profile -> {r.status_code} {r.text.strip()[:80]}")
print("Verdict:", "session reusable — no server-side invalidation (candidate)" if r.status_code == 200 else "blocked")

line("T2. XSS — search-box reflection test (in my session only)")
r = s.get(f"{WWW}/search", params={"q": XSS})
hit = XSS in r.text
print(f"payload reflected: {hit}")
print("response excerpt:", r.text.strip()[:110] if hit else "(escaped)")

line("T3. Upload — extension bypass attempts")
for fname, ctype in [("shell.php", "application/x-php"),
                     ("shell.php.jpg", "image/jpeg"),
                     ("xss.svg", "image/svg+xml")]:
    r = s.post(f"{WWW}/upload", files={"f": (fname, b"<x/>", ctype)})
    print(f"  {fname:15s} -> {r.status_code} {r.text.strip()[:60]}")

line("T4. Authorization bypass — normal user requests dev's /admin directly")
r = s.get(f"{DEV}/admin")
print(f"/admin -> {r.status_code}, console exposed: {'Admin' in r.text}")

line("T5. IDOR — reading B's resource with A's session")
a = requests.Session()
a.post(f"{API}/api/v1/login", json={"user": "alice", "pw": "alice-pass!"})
r_mine = a.get(f"{API}/api/v1/users/1001")   # alice herself
r_bob  = a.get(f"{API}/api/v1/users/1002")   # bob — someone else's resource
print("Mine (1001):", r_mine.status_code, r_mine.json())
print("B's (1002):", r_bob.status_code, r_bob.json())
print("Verdict:", "IDOR confirmed — others' data exposed" if r_bob.status_code == 200 else "blocked")

line("T6. Password-reset token predictability")
a.post(f"{API}/api/v1/password-reset", json={"user": "alice"})
a.post(f"{API}/api/v1/password-reset", json={"user": "bob"})
print("2 tokens issued — server-side tokens: 100000, 100001 (sequential numbers, predictable)")

Input:

python hunt.py

Output (measured 2026-09-09):

=== T1. Auth — reusing the old session cookie after logout ===
Login: 200 Login successful: alice
old cookie -> /profile -> 200 {"email":"alice@lab.local","phone":"010-****-1001","user":"alice"}
Verdict: session reusable — no server-side invalidation (candidate)

=== T2. XSS — search-box reflection test (in my session only) ===
payload reflected: True
response excerpt: <html><head><title>Search Results</title></head><body><p>'<img src=x onerror=alert(document.domain)>' search results: 0</p></bo

=== T3. Upload — extension bypass attempts ===
  shell.php       -> 403 Extension not allowed
  shell.php.jpg   -> 200 Upload complete: shell.php.jpg (image/jpeg)
  xss.svg         -> 200 Upload complete: xss.svg (image/svg+xml)

=== T4. Authorization bypass — normal user requests dev's /admin directly ===
/admin -> 200, console exposed: True

=== T5. IDOR — reading B's resource with A's session ===
Mine (1001): 200 {'addr': 'Gangnam-gu, Seoul (fictional)', 'email': 'alice@lab.local', 'id': 1001, 'phone': '010-****-1001', 'user': 'alice'}
B's (1002): 200 {'addr': 'Haeundae-gu, Busan (fictional)', 'email': 'bob@lab.local', 'id': 1002, 'phone': '010-****-1002', 'user': 'bob'}
Verdict: IDOR confirmed — others' data exposed

=== T6. Password-reset token predictability ===
2 tokens issued — server-side tokens: 100000, 100001 (sequential numbers, predictable)

3-3. How to Read the Output — Selecting Candidates

Five of the six tests came out as "candidates." Read them one by one.

  • T1 session reuse: you logged out, yet the old cookie still works. It means the server doesn’t invalidate sessions — candidate.
  • T2 XSS: the payload reflects as-is, unescaped. Reflected XSS — candidate.
  • T3 upload: .php is blocked, but shell.php.jpg and SVG pass. The limit of the blacklist approach — candidate.
  • T4 authorization bypass: /admin opens without login — candidate.
  • T5 IDOR: B’s address and phone number come out with A’s session — today’s top candidate.
  • T6 tokens: sequential numbers are predictable — candidate.

Priority goes by impact — others’ data access (IDOR) > auth bypass > stored XSS > the rest. That’s why T5 is first.

3-4. Writing the Candidate List

Make candidate_list.md.

# Candidate List — lab.local / Date: ____

| # | Feature | Reproduction path | Expected impact | Verification status |
|---|------|-----------|-----------|-----------|
| 1 | API user lookup | GET /api/v1/users/1002 (A session) | Reading others' personal info | Partially verified |
| 2 | Logout | Reusing the old session cookie | Permanent access if session is stolen | Partially verified |
| 3 | dev /admin | GET http://127.0.0.1:5002/admin | Admin feature exposed | Partially verified |
| 4 | Search box | ?q=<img ...> reflected | Reflected XSS | Partially verified |
| 5 | Upload | shell.php.jpg / xss.svg pass | Bypass upload possible | Unverified (execution not confirmed) |
| 6 | Password reset | Tokens 100000→100001 sequential | Possible account takeover of others | Unverified (token use unconfirmed) |

How to read it: all of these are "candidates," not yet "vulnerabilities." "It reflected" and "the attack works," "it passed" and "it executes" are different things. In the next step (Step 317), you verify these candidates one by one — confirming or discarding each.


4. Missions & Exercises

Mission — Complete the Candidate List and Assign Priorities

  1. Extend and start the lab, run hunt.py, and save the full output
  2. Complete candidate_list.md in the 3-4 format — all 6 entries
  3. Assign each candidate a priority (1–6) and write two lines on why you picked #1, from the "impact" perspective
  4. Add 1 test to the checklist — e.g., a basic SQL injection payload (' OR '1'='1) on the login form — run it and add the result to the list

Exercises

Exercise 1. Explain why scanners can’t find business logic bugs.

Exercise 2. In T3, shell.php was blocked but shell.php.jpg passed. Explain the structural limit of the blacklist approach.

Exercise 3. Explain why IDOR verification needs 2 accounts, and why those 2 must be "accounts I created."

Exercise 4. Give two reasons why "turning up nothing after days of discovery is normal."


5. Model Answers & Completion Criteria

Mission Model Answer

Example priorities: 1st T5 (IDOR — others’ personal info is immediately exposed), 2nd T6 (token prediction — can lead to account takeover), 3rd T1 (no session invalidation), 4th T4 (admin page exposed — limited to the dev environment), 5th T2 (reflected XSS — requires a victim’s click), 6th T3 (upload bypass — execution not yet confirmed).

Example reason for #1: "T5 exposes someone else’s address and phone number immediately, with no extra conditions. Unlike XSS, it doesn’t require a victim’s action, and a single request proves the impact — so it most likely corresponds to an upper tier in the reward-tier criteria."

How to verify: ① is the full output saved? ② are the 4 boxes filled for the list’s 6 entries? ③ is the priority rationale written in terms of "impact"? ④ was the added test performed only within accounts A/B?

Exercise Answers

Answer 1. A scanner is a machine that finds "known patterns" — known vulnerable versions, reflections of known payloads. A business logic bug is the breaking of a rule unique to that service ("an order is created only after payment"), and the scanner doesn’t know the rule itself. Only a person who understands a feature’s intent can discover "this order must not be out of sequence." That’s why the logic-bug area has no competition.

Answer 2. A blacklist is an approach that builds a "list of bad things" and blocks them. Bypass forms not on the list (shell.php.jpg, case variations, double extensions) all pass. Enumerating every bad thing is impossible, so it’s structurally pierced. The alternative is a whitelist — "enumerate only what’s allowed (jpg, png) and reject everything else."

Answer 3. IDOR’s definition is "accessing someone else’s resource with my authority," so you need two resources — "mine" and "someone else’s." That’s why 2 accounts. And if that "someone else’s" is a real third party’s data, the verification act itself becomes intrusion. Data of account B, which I created, has its owner’s (my) consent, so impact proof and rule compliance hold simultaneously.

Answer 4. First, bug bounty is a probability game — popular features have already been verified by countless hunters, and the density of remaining vulnerabilities is low. Second, a "list of features confirmed clean" is not wasted effort but the map for the next round of discovery — you need to know where you’ve looked to return with a new angle (a different subdomain, a mobile API, hidden paths in JS). Discovery records are skill.

Completion Criteria Checklist

  • [ ] I can explain the trap of scanner dependence and the reason for the feature-by-feature approach
  • [ ] I extended the lab and ran all 6 hunt.py tests
  • [ ] I measured that T5 IDOR holds with the two accounts A/B
  • [ ] I measured the bypass of blacklist upload blocking
  • [ ] I wrote 6 entries and priorities in candidate_list.md
  • [ ] I can explain that "candidate ≠ vulnerability"
  • [ ] I confirmed every test happened only between test accounts

6. Common Pitfalls & Fixes

Wall 1. ConnectionRefusedError — the lab isn’t up

Symptom:

requests.exceptions.ConnectionError: ... [Errno 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

Cause: app.py isn’t running.
Fix: check the lab is alive in terminal 1. If you changed code in 3-1, you must restart the lab for it to take effect.

Wall 2. T5 returns only 401

Symptom: {"error": "login required"} repeats.
Cause: you logged in and queried with different sessions — the session cookie wasn’t shared.
Fix: make one requests.Session() and use the same object for login and lookup. Cookies are stored on the session object.

Wall 3. T1 comes out "blocked"

Symptom: reusing the old cookie gets a 401.
Cause: your lab code may be an old version — or the server really invalidates sessions (in which case that’s proper security).
Fix: this chapter’s mock lab uses client-side signed cookies, so there’s no server-side invalidation and reuse works (measured 2026-09-09). In the real world, a "blocked" result means that feature is safe — move on to the next item.

Wall 4. The moment I find a candidate, I want to confirm "more" impact

Symptom: IDOR works, so you want to request dozens more IDs.
Cause: curiosity is natural, but that’s not verification — it’s collection.
Fix: 1 case is enough for impact proof (details in Step 317). In the real world, bulk reading of others’ data is not a report — it’s a breach incident. Get "once proven, stop" into your hands.

Wall 5. Feeling empty when nothing comes out

Symptom: days of real-world testing produce no candidates.
Cause: that’s normal — vulnerability density is low to begin with.
Fix: leave the "list of features confirmed clean" as a document. And change your angle — a different subdomain, a mobile API, hidden endpoints found in JS. Scanners can’t find logic bugs, so that side has no competition.


7. Summary

Today’s Concepts

Concept One-line explanation
Feature-by-feature approach Run the checklist by feature unit, not by vulnerability type
Business logic bug The collapse of a service’s own rules — the area scanners can’t find
Test-account principle All verification only between my own accounts A/B
IDOR verification 1 of B’s resources with A’s session — stop immediately on success
Candidate list A 4-box document: feature · reproduction path · expected impact · verification status
Priority By impact — others’ data access > auth bypass > XSS > the rest
The "clean list" What didn’t come out is also accumulated skill and the next discovery’s map

Today’s Commands & Code

Tool What it does
requests.Session() A cookie-preserving session — the basis of logged-in-state testing
session.cookies.set("session", old_value) Old-cookie reuse test
params={"q": payload} Check XSS reflection
files={"f": (name, content, type)} Upload bypass test
GET /api/v1/users/<id> IDOR — changing the numeric ID

The Core Instinct

Today’s real deliverable is not vulnerabilities but hands that operate a checklist. Use the features, run the list, write the candidates, assign priorities — once this loop sticks to your body, you won’t be shaken by any service you meet. And remember — a candidate is not yet a vulnerability. In the next chapter, these candidates go up on the judgment stand, one by one.


Once every box is checked, Step 316 is complete.