티스토리 뷰

728x90
반응형

문제 첫 페이지

처음에 접속하면 위와 같은 페이지가 나옵니다. 그리고 바로 제공받은 소스코드 파일을 보았습니다.

이번 문제는 소스코드 분석 후에 문제를 풀었습니다.

 

소스코드 파일이 꽤 많은데 중요 파일의 내용만 같이 보도록 하겠습니다.

  • Dockerfile
EXPOSE 80
WORKDIR /app
  • config/nginx.conf
listen 80;
  • challenge/wsgi.py
app.run(host='0.0.0.0', port=80)
  • challenge/application/blueprints
from flask import Blueprint, request, render_template, abort, send_file
from application.util import cache_web, is_from_localhost

web = Blueprint('web', __name__)
api = Blueprint('api', __name__)

@web.route('/')
def index():
    return render_template('index.html')

@api.route('/cache', methods=['POST'])
def cache():
    if not request.is_json or 'url' not in request.json:
        return abort(400)
    
    return cache_web(request.json['url'])

@web.route('/flag')
@is_from_localhost
def flag():
    return send_file('flag.png')

우선 /flag 경로가 존재했지만 is_form_locahost 함수에서 localhost 로 들어오는 접속 외에는 접근하지 못하도록 하였고, /cache 경로에서는 json 요청만 받게 하고 있고 cache_web 함수로 들어온 url 파라미터의 값을 넘겨주도록 하고 있습니다. 그리고 위에 import 문을 보면 알다시피 중요 함수들은 util.py 에서 불러오고 있음을 알 수 있습니다.

  • challenge/application/util.py
import functools, signal, struct, socket, os
from urllib.parse import urlparse
from application.models import cache
from flask import request, abort

generate = lambda x: os.urandom(x).hex()

def flash(message, level, **kwargs):
    return { 'message': message, 'level': level, **kwargs }

def serve_screenshot_from(url, domain, width=1000, min_height=400, wait_time=10):
    from selenium import webdriver
    from selenium.webdriver.firefox.options import Options
    from selenium.webdriver.support.ui import WebDriverWait

    options = Options()

    options.add_argument('--headless')
    options.add_argument('--no-sandbox')
    options.add_argument('--ignore-certificate-errors')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-infobars')
    options.add_argument('--disable-background-networking')
    options.add_argument('--disable-default-apps')
    options.add_argument('--disable-extensions')
    options.add_argument('--disable-gpu')
    options.add_argument('--disable-sync')
    options.add_argument('--disable-translate')
    options.add_argument('--hide-scrollbars')
    options.add_argument('--metrics-recording-only')
    options.add_argument('--no-first-run')
    options.add_argument('--safebrowsing-disable-auto-update')
    options.add_argument('--media-cache-size=1')
    options.add_argument('--disk-cache-size=1')
    options.add_argument('--user-agent=MiniMakelaris/1.0')

    options.preferences.update(
        {
            'javascript.enabled': False
        }
    )

    driver = webdriver.Firefox(
        executable_path='geckodriver',
        options=options,
        service_log_path='/tmp/geckodriver.log',
    )

    driver.set_page_load_timeout(wait_time)
    driver.implicitly_wait(wait_time)

    driver.set_window_position(0, 0)
    driver.set_window_size(width, min_height)

    driver.get(url)

    WebDriverWait(driver, wait_time).until(lambda r: r.execute_script('return document.readyState') == 'complete')

    filename = f'{generate(14)}.png'

    driver.save_screenshot(f'application/static/screenshots/{filename}')

    driver.service.process.send_signal(signal.SIGTERM)
    driver.quit()

    cache.new(domain, filename)

    return flash(f'Successfully cached {domain}', 'success', domain=domain, filename=filename)

def cache_web(url):
    scheme = urlparse(url).scheme
    domain = urlparse(url).hostname

    if not domain or not scheme:
        return flash(f'Malformed url {url}', 'danger')
        
    if scheme not in ['http', 'https']:
        return flash('Invalid scheme', 'danger')

    def ip2long(ip_addr):
        return struct.unpack('!L', socket.inet_aton(ip_addr))[0]
    
    def is_inner_ipaddress(ip):
        ip = ip2long(ip)
        return ip2long('127.0.0.0') >> 24 == ip >> 24 or \
                ip2long('10.0.0.0') >> 24 == ip >> 24 or \
                ip2long('172.16.0.0') >> 20 == ip >> 20 or \
                ip2long('192.168.0.0') >> 16 == ip >> 16 or \
                ip2long('0.0.0.0') >> 24 == ip >> 24
    
    if is_inner_ipaddress(socket.gethostbyname(domain)):
        return flash('IP not allowed', 'danger')
    
    return serve_screenshot_from(url, domain)

def is_from_localhost(func):
    @functools.wraps(func)
    def check_ip(*args, **kwargs):
        if request.remote_addr != '127.0.0.1' or request.referrer:
            return abort(403)
        return func(*args, **kwargs)
    return check_ip

cache_web 함수와 serve_screenshot_from 함수가 이 문제의 메인 함수들입니다. 우선 cache_web 의 경우에는 본 문제의 첫 웹페이지에서 URL을 입력하고 Submit 버튼을 누르면 실행되는데, 들어온 URL의 scheme, domain, ip 들을 검사합니다. 언뜻 보면 is_inner_ipaddress 부분에서 모든 로컬호스트에 해당하는 IP주소들을 필터링하고 있음을 알 수 있습니다. 그리고 serve_screenshot_from 함수에서는 들어온 URL을 가지고 web_driver 를 통해 스크린샷을 찍은 다음에 application/static/screenshots/{filename} 경로로 이미지 파일을 저장하도록 합니다. 그리고 해당 파일을 메인 웹페이지에 출력해줌으로써 동작을 마칩니다. (문제 풀이에 그렇게 중요하지 않은 데이터베이스 관련 함수와 파일에 대한 설명은 생략하겠습니다.)

 

그리고 가장 중요한 flag 파일은 application 디렉토리에 flag.png 파일로 존재한다고 합니다.

문제파일로부터 제공받은 flag.png를 열어보면 무슨 의미인지 알 수 없는 파일 내용이 들어있습니다.

우선 문제는 결국 localhost 로 접속하여 flag.png 파일을 열어서 봐야하는 것 같습니다. 하지만 소스코드에 적힌 로컬호스트 IP 주소에 대한 필터링은 꽤 상당해서 우회할 방법은 생각나지 않습니다. 그래서 우선은 숫자로된 IP주소를 사용하는 것을 포기하고 도메인 주소를 이용할 방법을 생각해보기로 했습니다.

 

그렇게 한창동안 골머리를 썩이다가 갑자기 저 위 flag.png 파일에 나와있는 단어가 보였고, Rebinding? binding? bind는 일반적으로 domain name 또는 ip 주소를 binding 한다는 느낌으로 쓰인다는 것이 문득 떠올라 구글에 domain name rebinding 이라고 검색해보았고, 결국 DNS Rebinding 이라는 결론으로 도달했습니다.

https://en.wikipedia.org/wiki/DNS_rebinding

 

DNS rebinding - Wikipedia

DNS rebinding is a method of manipulating resolution of domain names that is commonly used as a form of computer attack. In this attack, a malicious web page causes visitors to run a client-side script that attacks machines elsewhere on the network. In the

en.wikipedia.org

(살짝 생소한 개념이라 몇번을 읽어보고 나서 이해했습니다.)

 

그리고 해당 취약점을 테스트하기 위해서 어떻게 하면 좋을까 뒤적뒤적하다가 좋은 툴을 찾을 수 있었습니다.

https://lock.cmpxchg8b.com/rebinder.html

 

rbndr.us dns rebinding service

 

lock.cmpxchg8b.com

위 사이트는 DNS rebinding 기법을 기반으로 특정 IP 주소를 다른 IP주소로 서로 바꿔주는 역할을 해주는 좋은 도구입니다.

 

그리고 저는 hackthebox 로부터 제공받은 문제의 IP주소를 localhost IP 주소로 변환해야 했기 때문에 아래와 같이 변환하였습니다.

그리고 위 사이트로부터 제공받은 도메인을 가지고서 문제 페이지에 http://bca6add0.7f000001.rbndr.us/flag 와 같은 형식으로 input 에 입력하고 Submit 버튼을 눌렀습니다.

 

그러더니 IP not allowed 메시지가 뜹니다. 원하던 결과는 바로 안나왔지만 좋은 징조였습니다. 분명 관련 없는 도메인을 입력하였는데, localhost 일 때 나와야하는 메시지가 나온 것입니다.

 

이후 성공할 때까지 여러번 시도해보았고, 몇 번은 web_driver 관련 오류가 뜨다가 어느 순간 웹 서버의 chrome_driver 가 http://localhost/flag 페이지의 스크린샷을 성공적으로 캡쳐하고 특정 경로인 application/static/screenshots/ 에 저장하고서 페이지에 flag.png 사진을 띄워주는 것을 볼 수 있게됩니다.

결과적으로 이미지 파일에 작성되어 있는 Flag 는 다음과 같습니다.

Flag는 문제 풀이의 재미를 위해서 제거하였습니다.

 

728x90
반응형
댓글