-
[Study] Real World CTF 4th Hack into Skynet WriteupCTF/Real World CTF 4th 2022. 1. 24. 21:02
주어진 페이지에 접근해보자.
다음과 같은 로그인페이지가 나타난다.
취약점을 찾기 위해 주어진 소스코드를 분석해보자.
#!/usr/bin/env python3 import flask import psycopg2 import datetime import hashlib from skynet import Skynet app = flask.Flask(__name__, static_url_path='') skynet = Skynet() def skynet_detect(): req = { 'method': flask.request.method, 'path': flask.request.full_path, 'host': flask.request.headers.get('host'), 'content_type': flask.request.headers.get('content-type'), 'useragent': flask.request.headers.get('user-agent'), 'referer': flask.request.headers.get('referer'), 'cookie': flask.request.headers.get('cookie'), 'body': str(flask.request.get_data()), } _, result = skynet.classify(req) return result and result['attack'] @app.route('/static/<path:path>') def static_files(path): return flask.send_from_directory('static', path) @app.route('/', methods=['GET', 'POST']) def do_query(): if skynet_detect(): return flask.abort(403) if not query_login_state(): response = flask.make_response('No login, redirecting', 302) response.location = flask.escape('/login') return response if flask.request.method == 'GET': return flask.send_from_directory('', 'index.html') elif flask.request.method == 'POST': kt = query_kill_time() if kt: result = kt else: result = '' return flask.render_template('index.html', result=result) else: return flask.abort(400) @app.route('/login', methods=['GET', 'POST']) def do_login(): if skynet_detect(): return flask.abort(403) if flask.request.method == 'GET': return flask.send_from_directory('static', 'login.html') elif flask.request.method == 'POST': if not query_login_attempt(): return flask.send_from_directory('static', 'login.html') else: session = create_session() response = flask.make_response('Login success', 302) response.set_cookie('SessionId', session) response.location = flask.escape('/') return response else: return flask.abort(400) def query_login_state(): sid = flask.request.cookies.get('SessionId', '') if not sid: return False now = datetime.datetime.now() with psycopg2.connect( host="challenge-db", database="ctf", user="ctf", password="ctf") as conn: cursor = conn.cursor() cursor.execute("SELECT sessionid" " FROM login_session" " WHERE sessionid = %s" " AND valid_since <= %s" " AND valid_until >= %s" "", (sid, now, now)) data = [r for r in cursor.fetchall()] return bool(data) def query_login_attempt(): username = flask.request.form.get('username', '') password = flask.request.form.get('password', '') if not username and not password: return False sql = ("SELECT id, account" " FROM target_credentials" " WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest()) user = sql_exec(sql) name = user[0][1] if user and user[0] and user[0][1] else '' return name == username def create_session(): valid_since = datetime.datetime.now() valid_until = datetime.datetime.now() + datetime.timedelta(days=1) sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest() sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)" " VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until)) return sessionid def query_kill_time(): name = flask.request.form.get('name', '') if not name: return None sql = ("SELECT name, born" " FROM target" " WHERE age > 0" " AND name = '{}'").format(name) nb = sql_exec(sql) if not nb: return None return '{}: {}'.format(*nb[0]) def sql_exec(stmt): data = list() try: with psycopg2.connect( host="challenge-db", database="ctf", user="ctf", password="ctf") as conn: cursor = conn.cursor() cursor.execute(stmt) for row in cursor.fetchall(): data.append([col for col in row]) cursor.close() except Exception as e: print(e) return data def sql_exec_update(stmt): data = list() try: with psycopg2.connect( host="challenge-db", database="ctf", user="ctf", password="ctf") as conn: cursor = conn.cursor() cursor.execute(stmt) conn.commit() except Exception as e: print(e) return data if __name__ == "__main__": app.run(host='0.0.0.0', port=8080)
로그인 시도 시 일반적은 sql injection 구문을 삽입해보면 아래와 같이 나타난다.
이는 Skynet에 의해 탐지되어서인데, Skynet 자체를 분석하는것은 문제가 아니라고 설명이 되어있었으므로, 주어진 소스 내에서 취약점을 찾아보았다.
로그인 시도 부분을 자세히 살펴보도록 하자.
def query_login_attempt(): username = flask.request.form.get('username', '') password = flask.request.form.get('password', '') if not username and not password: return False sql = ("SELECT id, account" " FROM target_credentials" " WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest()) ##### ................................................................... (1) user = sql_exec(sql) name = user[0][1] if user and user[0] and user[0][1] else '' ##### ............. (2) return name == username ##### .................................................. (3)
최종적으로 (3)에 의해 쿼리조회된 결과값인 name과 사용자 입력 ID가 일치할시 로그인이 성공하게되는데, 인증구조가 다소 독특한것을 알 수 있었다.
(1) 부분을 보면 조회 쿼리에 ID는 사용되지 않는다. 또한, (2)와 같이 쿼리의 결과값이 없으면, name 파라미터를 빈 값으로 정의한다.
따라서, username을 입력하지 않고, 패스워드를 아무거나 입력하여 쿼리 결과 값의 name 또한 '' 으로 정의된다면, name과 username 모두 빈 문자열이 되므로 조건을 만족하여 로그인에 성공 할 수 있을것이라고 추측했다.
위와 같이 username 파라미터의 값을 비우고 요청을 전송하면,
로그인에 성공함을 확인 할 수 있었다.
다음 분석할 부분은 query_kill_time() 메소드이다. SQL injection 공격을 수행하기에 별다른 문제점이 없어보이지만, 역시 Skynet에 의해 차단되어서 공격이 막혔다.
def query_kill_time(): name = flask.request.form.get('name', '') ##### ............................... (1) if not name: return None sql = ("SELECT name, born" " FROM target" " WHERE age > 0" " AND name = '{}'").format(name) nb = sql_exec(sql) if not nb: return None return '{}: {}'.format(*nb[0])
처음에는 특이한 구문을 사용하면 탐지를 하지 못할까해서 이것저것 시도해봤는데 잘 되지 않았다.
def skynet_detect(): req = { 'method': flask.request.method, 'path': flask.request.full_path, 'host': flask.request.headers.get('host'), 'content_type': flask.request.headers.get('content-type'), 'useragent': flask.request.headers.get('user-agent'), 'referer': flask.request.headers.get('referer'), 'cookie': flask.request.headers.get('cookie'), 'body': str(flask.request.get_data()), ##### ........................... (2) } _, result = skynet.classify(req) return result and result['attack']
그래서 코드를 다시한번 천천히 분석해보았다. query_kill_time()의 (1)은 flask.request.form.get() 메소드를 이용 하고 있었고, Skynet은 (2)에 의해 flask.request.get_data() 메소드를 통해 수집한 데이터를 대상으로 탐지를 수행하고 있었다.
로컬서버를 구축하여
flask.request.form.get()
flask.request.get_data()
이 각각 어떻게 읽히나 찍어보다가 content-type을 바꿔보면 get_data()에서 읽히는 값과 form.get() 에서 읽히는 값이 상이하여 혼돈을 줄 수 있지 않을까 생각했고, multipart/form-data 형식으로 데이터를 보냈을 때, 아래처럼 get_data()에서는 복잡하게 읽히지만 form.get('name') 에서는 의도한 파라미터 값만 읽히는 것을 확인 했다.
문제 서버에 테스트해보니, 스카이넷의 탐지가 우회됨을 확인했다.
별다른 제한 없이 SQL Injection이 가능해졌으므로, 데이터베이스에서 플래그를 찾으면 된다.
... '; select column_name, 1 from information_schema.columns where table_name='target_credentials' limit 10 offset 4;-- ... '; select secret_key, '1' from target_credentials;--
FLAG = rwctf{t0-h4ck-$kynet-0r-f1ask_that-Is-th3-questi0n}
----------------------------------------------------------------------------------------------------------------------
참고
postgreSQL
SELECT name, born FROM target WHERE age > 0 AND name = ''||name||'' => SELECT name, born FROM target WHERE age > 0 AND name = name
문제 해결 후 다른 풀이를 보니, 위 구문으로 sql injection 유효함을 확인한 후,
헤더변조를 통한 탐지 우회를 사용하지 않고 그냥 구문을 날려서 문제를 해결한 케이스도 있었다. 테스트구문들 해봤을때 다 막히길래 스카이넷이 키워드 상관 없이 sql 문법 기준으로 판단하여 차단하는줄 알았는데 그건 또 아닌가보다..
(테스트구문 말고 실제 구문을 넣어볼걸그랬다.)
반응형