CTF · Wargames
Step 176. CTF First Taste 1: Five Web Challenges — Entering the Real Arena
Level 3 · Difficulty ★★★☆☆ · Plan about five hours; individual time varies
Prerequisites: Level 2 HTTP, cookies, information disclosure and access control. Bring Python3, Flask 3.1.3, curl and a browser. You can complete this lesson with a mini CTF on your own computer.
Observe five challenges, form hypotheses and submit flags. Check your understanding by comparing vulnerable and corrected behavior. The downloadable app is deliberately weak, for personal practice, and binds only to 127.0.0.1. Do not deploy it as a public service or put real personal data in it.
1. Goals and the Level 3 path
- Distinguish HTML body, comments, response headers, cookies and query parameters.
- Apply hypothesis→request→observation→submission to five challenges.
- Distinguish a flag’s format from validation of the answer.
- Distinguish signed cookies, server-side sessions and object-level authorization.
- Record independent solves, hints and reproduction after reading solutions, then choose a next action.
Level 3 contains 128 lessons, Steps176–303. Steps176–180 introduce Web, Pwn, Reversing, Crypto and Forensics/Misc; Step181 selects a main field. Steps182–189 cover assembly and first memory attacks; Step190 covers teams and competition schedules. The table groups topics for orientation; follow actual lesson-number order.
| Range | Topic |
|---|---|
| 176–181, 190 | Introductions, specialization and teams |
| 182–189 | Assembly, gdb and first memory attacks |
| 191–202 | Advanced web |
| 203–214 | Advanced Pwn |
| 215–226 | Advanced reversing |
| 227–238 | Crypto |
| 239–250 | Forensics |
| 251–272 | Practice machines and playbooks |
| 273–278 | Hard challenges and speed practice |
| 279–303 | Competitions, debriefs and Level 3 review |
2. CTF and observation
Jeopardy-style CTFs offer category-based challenges and accept answer strings. Rules, hints and source availability vary by event; do not generalize that CTFs have no hints. This lab uses FLAG{...}. Matching this shape does not prove correctness: both the challenge number and complete value must match.
List the visible features first. For each, hypothesize which inputs it trusts and which data it returns, then change one request at a time. An unexpected response alone is not proof of a vulnerability: compare intended access policy with actual behavior. Here you practice comments→public paths→headers→cookies→profiles.
| Command | Exact meaning |
|---|---|
curl -sS URL |
GET body; hide progress but show errors |
curl -sS -i URL |
GET response headers and body |
curl -sS -I URL |
Make a HEAD request; not merely remove the body from a GET result |
curl -sS --cookie "admin=true" URL |
Send the specified cookie |
In Windows PowerShell, use curl.exe for the commands below to avoid confusion with the Invoke-WebRequest alias. Flask adds HEAD support to these GET routes, but do not assume every server responds identically to both methods.
3. Open the local arena
3-1. Get the files and start the server
Extract the lab ZIP and change to that directory. It includes the server, reproducer, session demonstration, dependencies, bilingual worksheets/instructions and verification records. English instructions · Solving worksheet. Try independently before reading the solutions or flags in the source.
# Linux/macOS
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python step176_ctf.py --lang en --port 8176
# Windows PowerShell
py -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe step176_ctf.py --lang en --port 8176
Open http://127.0.0.1:8176/ in your browser. For Korean, stop your server normally and restart with --lang ko. If 8176 is occupied, identify its owner and move this lab to a free port such as --port 8178. Do not force-stop another process. Change all subsequent URL ports consistently.
The app was tested with Flask 3.1.3. It uses the development server with debugger and automatic reload disabled; administrator privileges are unnecessary. Scores live only in that server process’s memory and return to 0/5 after restart. Save your worksheet separately.
3-2. Attempt and submit first
Inspect robots.txt, Notice, Entrance and My profile from the arena and write five hypotheses. Submit the challenge number and discovered flag in the form. Incorrect values do not increase the score; repeating a correct answer does not double-count it. The JSON score is at /score.
curl -sS --data-urlencode "challenge=1" --data-urlencode "flag=PUT_THE_COMPLETE_FLAG_HERE" http://127.0.0.1:8176/submit
curl -sS http://127.0.0.1:8176/score
The server contains fixed teaching flags, so reading its source reveals the answers. This is a personal reproduction lab, not a competition grading service. Do not record an accepted submission as an independent solve if you read the solution.
4. Five walkthroughs
4-1. HTML comment
curl -sS http://127.0.0.1:8176/
Beyond the visible text, an HTML comment contains the first flag. Submit FLAG{v1ew_s0urce_1s_th3_f1rst_t00l} to challenge1. Information hidden from the rendered screen remains readable if delivered to the client. This does not mean source search solves every web challenge.
4-2. robots.txt and access control
curl -sS http://127.0.0.1:8176/robots.txt
curl -sS -i http://127.0.0.1:8176/secret_backup
robots.txt contains Disallow: /secret_backup; the actual request returns200 and FLAG{r0b0ts_txt_1s_n0t_a_d00r}. robots.txt specifies crawler rules, not requester authorization. Record the public path listing separately from the fact that the resource opens without authentication.
4-3. Flag in a header
curl -sS -I http://127.0.0.1:8176/notice
curl -sS -i http://127.0.0.1:8176/notice
Both HEAD and GET on this server return X-Flag: FLAG{r34d_th3_h34d3rs_c4r3fully}. HEAD has no body; GET’s body contains no flag. Date, Server and Content-Length vary by environment, so do not memorize them. X-Flag is a header created by this exercise; an X- prefix alone establishes neither security nor a vulnerability.
4-4. Untrusted administrator cookie
curl -sS -i http://127.0.0.1:8176/enter
curl -sS --cookie "admin=true" http://127.0.0.1:8176/enter
The first response includes Set-Cookie: admin=false. The altered cookie in the second request is enough to obtain FLAG{c00k13s_c4n_b3_c00k3d}. The cause is trusting an unverified client value as authority. Merely storing a cookie on a client is not itself a vulnerability.
4-5. Missing object authorization
curl -sS "http://127.0.0.1:8176/profile?uid=2"
curl -sS "http://127.0.0.1:8176/profile?uid=1"
Requesting another user’s number instead of the ordinary profile exposes FLAG{1d0r_ch4ng3_th3_numb3r}. The vulnerable mode has no real login either. IDOR’s core is a missing check that the requester may access the object, not simply an easy-to-guess number. Replacing numbers with long random values does not replace authorization.
5. Before/after comparison and sessions
5-1. Compare identical requests
Keep the challenge server and start the fixed comparison on8177 in a second terminal. On Windows, replace the Python path as shown earlier.
.venv/bin/python step176_ctf.py --mode fixed --role user --lang en --port 8177
curl -sS -i http://127.0.0.1:8177/secret_backup
curl -sS -i --cookie "admin=true" http://127.0.0.1:8177/enter
curl -sS -i "http://127.0.0.1:8177/profile?uid=1"
curl -sS -i "http://127.0.0.1:8177/profile?uid=2"
The observed results were403 for the backup, forged cookie and other profile, but200 for own profile2. The comment flag and X-Flag were removed; the robots.txt path remained. Access checks can work without hiding the route.
--role user/admin is a simulated identity chosen when the server starts. It is not a login, and browser inputs/cookies do not set it. Stop the comparison server and restart with --role admin: the authorized backup, entrance and profile1 return200. A real service must authenticate a principal and check role/object permissions on requests. This comparison is not a complete production security implementation.
5-2. Signed cookies and server-side sessions
A server-side session commonly uses an opaque cookie identifier to look up server storage. Flask’s default session is a signed cookie carrying data, a different design. A signature supports tamper detection; it is not encryption. Do not place sensitive information in a cookie expecting its signature to hide it. Successful signature verification also does not replace object-level authorization.
.venv/bin/python session_demo.py
This demonstration uses a Flask test client without a network listener. Decode the cookie’s data without the signing key to see role=user and note=readable. Change only the role to admin while retaining the original signature: verification rejects it and the resulting session is empty. This demonstrates default-session storage, readability and tamper checking, not the security of a complete real login.
5-3. Full source and automatic reproduction
Complete lab server · HTTP reproducer · Session demonstration. The source contains the answers; expand it after your independent attempt.
Expand the complete server source — contains flags
"""Five intentionally weak local CTF challenges and a separate fixed comparison.
Never deploy this teaching app as a public service. The fixed identity is a
server-side lab fixture, NOT a production login implementation.
"""
import argparse
import threading
from flask import Flask, jsonify, make_response, request
from markupsafe import escape
FLAGS = {
1: "FLAG{v1ew_s0urce_1s_th3_f1rst_t00l}",
2: "FLAG{r0b0ts_txt_1s_n0t_a_d00r}",
3: "FLAG{r34d_th3_h34d3rs_c4r3fully}",
4: "FLAG{c00k13s_c4n_b3_c00k3d}",
5: "FLAG{1d0r_ch4ng3_th3_numb3r}",
}
def create_app(mode="challenge", role="user", language="ko"):
if mode not in {"challenge", "fixed"} or role not in {"user", "admin"} or language not in {"ko", "en"}:
raise ValueError("Unknown lab configuration")
app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=4096, TRUSTED_HOSTS=["127.0.0.1", "localhost"])
solved = set()
lock = threading.Lock()
korean = language == "ko"
def text(ko, en):
return ko if korean else en
def page(title, body):
return f'''<!doctype html><html lang="{language}"><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>{escape(title)}</title>
<style>body{{font-family:system-ui,sans-serif;max-width:760px;margin:32px auto;padding:0 20px;line-height:1.7}}input,select,button{{font:inherit;padding:6px}}input{{max-width:90%}}code{{overflow-wrap:anywhere}}</style>
<h1>{escape(title)}</h1><p>{text('로컬 학습 실습','Local learning exercise')} · {text('취약 모드','Challenge mode') if mode=='challenge' else text('수정 비교 모드','Fixed comparison')}</p>{body}
<p><a href="/">{text('대회장으로','Back to arena')}</a></p></html>'''
@app.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
@app.get('/')
def index():
with lock:
count = len(solved)
comment = f'<!-- First flag / 첫 플래그: {FLAGS[1]} -->' if mode == 'challenge' else ''
form = f'''<h2>{text('플래그 제출','Submit a flag')}</h2>
<form action="/submit" method="post"><label>{text('문제 번호','Challenge number')} <select name="challenge">{''.join(f'<option>{n}</option>' for n in FLAGS)}</select></label><br>
<label>{text('찾은 플래그','Found flag')} <input name="flag" required maxlength="150" autocomplete="off"></label><br>
<button type="submit">{text('제출','Submit')}</button></form>''' if mode == 'challenge' else ''
body = f'''<p>{text('다섯 플래그를 찾고 관찰 근거를 기록하세요.','Find five flags and record the evidence for each.')}</p>
<ul><li><a href="/robots.txt">robots.txt</a></li><li><a href="/notice">{text('공지사항','Notice')}</a></li>
<li><a href="/enter">{text('입장','Entrance')}</a></li><li><a href="/profile?uid=2">{text('내 프로필','My profile')}</a></li></ul>
<p>{text('정답 처리','Accepted')}: {count}/5 · <a href="/score">{text('점수 JSON','Score JSON')}</a></p>{form}{comment}'''
return page(text('미니 CTF 대회장','Mini CTF Arena'), body)
@app.get('/robots.txt')
def robots():
return 'User-agent: *\nDisallow: /secret_backup\n', 200, {'Content-Type':'text/plain; charset=utf-8'}
@app.get('/secret_backup')
def backup():
if mode == 'fixed' and role != 'admin':
return page(text('접근 거부','Access denied'), '<p>403</p>'), 403
return page(text('백업','Backup'), f'<p>{FLAGS[2]}</p>')
@app.get('/notice')
def notice():
response = make_response(page(text('공지사항','Notice'), '<p>'+text('이 응답을 관찰하세요.','Inspect this response.')+'</p>'))
if mode == 'challenge':
response.headers['X-Flag'] = FLAGS[3]
return response
@app.get('/enter')
def enter():
allowed = request.cookies.get('admin') == 'true' if mode == 'challenge' else role == 'admin'
if allowed:
return page(text('관리자 입장','Admin entrance'), f'<p>{FLAGS[4]}</p>')
response = make_response(page(text('일반 사용자','Regular user'), '<p>'+text('관리자만 이 플래그를 볼 수 있습니다.','Only admins can see this flag.')+'</p>'), 200 if mode=='challenge' else 403)
if mode == 'challenge':
response.set_cookie('admin', 'false', samesite='Lax')
return response
@app.get('/profile')
def profile():
uid = request.args.get('uid', '2')
if uid not in {'1','2'}:
return page(text('잘못된 사용자 번호','Invalid user ID'), '<p>'+text('사용자 번호 1 또는 2를 입력하세요.','Use user ID 1 or 2.')+'</p>'), 400
if mode == 'fixed' and role != 'admin' and uid != '2':
return page(text('접근 거부','Access denied'), '<p>403</p>'), 403
content = FLAGS[5] if uid=='1' else 'user2'
return page(text('프로필','Profile'), f'<p>uid={escape(uid)} · {content}</p>')
@app.get('/score')
def score():
with lock:
return jsonify(solved=sorted(solved), accepted=len(solved), total=5, persistent=False)
@app.post('/submit')
def submit():
if mode != 'challenge':
return page(text('비교 모드','Comparison mode'), '<p>'+text('수정 비교 모드에서는 제출하지 않습니다.','Submission is disabled in comparison mode.')+'</p>'), 403
number = request.form.get('challenge', '')
flag = request.form.get('flag', '')
if number not in {'1','2','3','4','5'}:
return page(text('잘못된 문제 번호','Invalid challenge number'), ''), 400
accepted = flag == FLAGS[int(number)]
with lock:
duplicate = int(number) in solved
if accepted:
solved.add(int(number))
count = len(solved)
result = text('정답','Correct') if accepted else text('오답: 문제 번호와 전체 문자열을 확인하세요.','Incorrect: check the challenge number and complete string.')
if accepted and duplicate:
result += text(' · 이미 정답 처리됨',' · Already accepted')
return page(text('제출 결과','Submission result'), f'<p>{result}</p><p>{count}/5</p>')
return app
def port_number(value):
port = int(value)
if not 1024 <= port <= 65535:
raise argparse.ArgumentTypeError('Choose a port from 1024 to 65535')
return port
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--mode',choices=['challenge','fixed'],default='challenge')
parser.add_argument('--role',choices=['user','admin'],default='user',help='Server-side fixture for fixed mode; not a login')
parser.add_argument('--lang',choices=['ko','en'],default='ko')
parser.add_argument('--port',type=port_number,default=8176)
args = parser.parse_args()
create_app(args.mode,args.role,args.lang).run(host='127.0.0.1',port=args.port,debug=False,use_reloader=False)
if __name__=='__main__':
main()
.venv/bin/python reproduce.py
The reproducer starts its own servers on available loopback ports, obtains and submits five flags in both languages, then closes its servers. It does not stop your manual server or other services. On 2026-09-27, Windows Python3.12.10 and isolated Linux Python3.14.7, with Flask 3.1.3, passed 13 check categories per language. Windows also ran the actual curl.exe HEAD and cookie commands. The Linux test container lacked curl: its HTTP flow used Python and is not reported as a Linux curl execution.
Windows results · Linux results include component versions and source hashes. Tests covered fixed-mode403 as well as allowed own-profile/admin200 responses, wrong answers, duplicates, invalid numbers and fresh-server score reset. These results do not verify external CTF challenges or a student’s independent ability.
6. Worksheet, mission and questions
For five rows, record the challenge, first hypothesis, request/observation, cause, independent/hinted/after-solution status, actual time and fixed result. Do not fill times with example numbers. If you read a solution, reproduce it without the write-up the next day and add a record. End with one concrete next action, such as “I inspected only the body, so next time I will save GET response headers too.”
- Why submit even after finding a string shaped like FLAG{…}?
- Why does the fixed backup return403 although robots.txt is unchanged?
- How do
curl -Iandcurl -idiffer? - How does Flask’s signed session differ from a server-side session and encrypted data?
- Which trust/permission check is missing in the cookie and profile challenges respectively?
- What defect can a test of only unauthorized403 responses miss?
Answers
- Shape is only a search clue. The challenge number and whole value must match; it may be a decoy or a different challenge’s answer.
- The server checks the requester’s permission. Crawler rules and authorization serve different purposes.
- -I sends HEAD; -i includes headers in a GET result. This app supports both, but other servers may produce different status/headers.
- Flask defaults to client-held data in a signed cookie. It is readable, but changing it without the proper key fails verification. It is neither an opaque lookup ID nor encryption.
- The cookie challenge trusts an unverified admin value. The profile challenge omits permission to access the selected object. An authenticated user is not automatically authorized for every object.
- An implementation that blocks everyone could pass. Also verify200 responses for permitted requests such as one’s own object and an authorized administrator.
7. Troubleshooting and completion
- Flask module missing: use the Python from the virtual environment where requirements were installed.
- Connection refused: inspect startup errors, address and port. If occupied, change the lab port instead of force-stopping another service.
- Garbled Korean: save with
curl.exe -sS -o response.html URLand read as UTF-8, or use the browser. Do not repeatedly convert without identifying the encoding. - Incorrect flag: check challenge number, case, braces and unwanted whitespace. Do not include surrounding quotation marks from a walkthrough.
- Cookie change ineffective: check the –cookie argument, challenge/fixed mode and destination port. Fixed-mode403 is the intended comparison.
- External problems are harder: these are five simple cases. Do not equate them to platform difficulty ratings; record whether concepts, tools or hypotheses caused the block.
Complete the lesson by reproducing requests, responses and submissions for all five challenges, explaining before/after behavior, and saving five worksheet rows plus self-assessment. Mark cases you did not solve independently and retain a next attempt. Stop servers you started with Ctrl+C when finished.
Official references: Flask sessions · curl options · Robots protocol · OWASP object authorization.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.