ACM 2019-2020 CTF1 Writeups: Todd, Stego, Trivia, &c

Posted 12/08/19 #security #ctf #acm

ACM UMN runs CTFs (Capture The Flag) cybersecurity competitions during the academic year. The one this past week was a jeopardy-style CTF, meaning that there were a number of challenges to be individually solved by teams (not directly competitive like an A/DCTF.) This is a writeup of some of the problems that I pushed for this competition.

Todd 1

The Todd challenges were a series of web exploration challenges. They were all in the form of a poorly-written DIY logging platform. These had an "admin panel" that is where a real blog would have it's post creating controls &c. Since everyone was pwning a shared instance of the blog there weren't any actual controls on the admin page, just the flag for the CTF.

The first of these had a "Admin Log In" link on the navbar that took you to a login page. This login page implemented the username and password authentication in client-side Javascript:

$(document).ready(function() {
        if($("#username").val() == "todd" &&
           $("#password").val() == "secretpassword1!") {
            document.location = "/admin";
        } else {
            alert("Incorrect Password!");

Looking at this we can see that the username todd and password secretpassword1! will work in the login form. Of course you could also skip the login page and navigate straight to /admin!

Todd 2

The second Todd challenge removes the login page entirely. Instead the admin panel is mounted at a route with some random characters in it. One of the blog posts on the site mentions that "I had to take some steps to make it so it wouldn't show up in searches." This is a hint that he (mistakenly) thinks the page would be indexed by Google or other spiders (in actuality since there's no links to it, there'd be no way for a search provider to find it anyway.)

To prevent spiders from indexing it, he's employed the robots.txt standard. The file at /robots.txt looks like this:

User-agent: *
Allow: /
Allow: /post/*
Allow: /about
Disallow: /secret_stuff_DSz37lLsqY5JEFgEdRPM/

From this we can tell that the admin panel is at /secret_stuff_DSz37lLsqY5JEFgEdRPM/, and since there's no auth that's all we need.

Todd 3

For this challenge we're back to a login page. However the login page does authentication checking both client-side and server-side. To avoid just exposing the password in JS Todd has employed a hash function to obscure the password:

$(document).ready(function() {
        if($("#username").val() == "todd" &&
           toddhash($("#password").val()) == 1843) {
            document.location = "/admin/"+$("#password").val();
        } else {
            alert("Incorrect Password!");

This means that you need a password that matches the hash for the redirect to occur, but the password is also enforced on the server (also via hash.) Thankfully, Todd has picked a comically bad hash:

function toddhash(str){
    sum = 0;
    for (var i = str.length - 1; i >= 0; i--) {
        sum = sum + str.charCodeAt(i);
    return sum;

It's simply the sum of the character codes! More concisely in python:

def hash(pw): return sum(map(ord, pw))

Since we know the (really easy) hash function and the hash value (1843) we can come up with a password that matches it. The password that todd used was leavemyblogalone!l, but it turns out that 1843 is a multiple of the character code of a: 1843/97 = 19, and so aaaaaaaaaaaaaaaaaaa works as a password for the blog as well!

Todd 4

In this challenge Todd has a reasonably-correctly-implemented login form that does all of the checking on the server. Unfortunatley, he has implemented the authentication by setting a cookie, either admin=0 or admin=1:

def is_admin():
    return request.cookies.get('admin') == '1'

As a result getting into the admin page is as simple as setting admin=1 and then navigating to /admin (directly or via the navbar.)

Todd 5

Todd has implemented a session ID system in this iteration. This means that each user is assigned a number that identifies the session to the server, and the authentication status (and selected background color) for each session is stored entirely on the server. Unfortunately, there's way too few session numbers possible. If you repeatedly delete your toddsession cookie you'll note that it's always between 000 and 999 (and padded to three characters). This means that you can simply write a script to try all 1000 sessions and see if any of them cause an admin link to appear in the navbar (meaning that whoever's session that really is is logged in as an admin.) Since this is a CTF I implemented the server so that session 169 is always logged in, and so it's always possible to get into the admin panel. (It's also actually set up so that you can't sign out of an admin session, since otherwise the first person to find the session could lock others out.)

N.B.: There was initially a bug that both the chal and the actual CTFd scoring server used a cookie with the name session and so visiting the chal would wipe your CTFd session, and vice versa. That's why the cookie got renamed to toddsession, sorry!

Stego 1

This challenge hid a black-and-white bitmap image in a full-color bitmap by embedding it in the least significant bit of one of the color channels (blue.) This is a classic stego trick. Here's the code to pull it out as written in pygame (a python layer over SDL):

import pygame
import math


scr = pygame.display.set_mode((1355, 750))
scr.blit(pygame.image.load("img.bmp"), (0,0))

for x in range(1355):
    for y in range(750):
        r, g, b, _ = scr.get_at((x, y))
        if b % 2:
            scr.set_at((x, y), (255, 0, 0))
            scr.set_at((x, y), (0,0,0))


Stego 2

This one is a little more interesting. It's basically the same scheme, but instead it's embedded in the "low bits" of the value value when the colors are treated as a HSV color. Since there isn't a 1:1 mapping between changes in HSV colorspace and in RGB colorspace, the embedding is actually done by rounding the value value to a multiple of 6 for each pixel in the image, and then adding 3 to those that should be set logically high. Here's the python implementation of the decoder:

import pygame
import math


scr = pygame.display.set_mode((930, 1860))
scr.blit(pygame.image.load("out.bmp"), (0,0))

for x in range(930):
    for y in range(1860):
        r, g, b, _ = scr.get_at((x, y))
        h, s, v, a = pygame.Color(r, g, b).hsva

        if abs(3 - (v % 6))>=2:
            scr.set_at((x, y), (255, 0, 0))
            scr.set_at((x, y), (0,0,0))


N.B.: Some people got this one by just looking at the luminance of the image. Since it was a screenshot you could tell (with some effort) what was embedded. In the future I'll have to use the hue or saturation channel (or choose an image with a much wider luminosity range) to avoid this also working, since it wasn't the "fun" solution.

Stego 3

This one was a little different, since it was a text stego problem. I took a copy of Hamlet and replaced some of the characters with Unicode "lookalikes," in such a way that looking at only the Unicode characters would result in a (Unicode-lookalike) flag that was correct when represented using the non-lookalike characters. Here's a python script that strips out only the Unicode chars:

import random
import string

with open('doc.txt', 'r') as fd:
    text =

for c in text:
    if c not in string.printable:
        print(c, end='')


Running this will produce ๐•ฑ๐‘ขฒ๐ด๐‘ฎยซ๐–ฒ๏ผดโ‹ฟ๐‘ฎโ—‹๏ผด๐œ๐‘โ‹ฟโ‹ฟโ‹ฎ๏ผด๐œ๏ผฉ๐–ฒ๏ผฉ๐–ฒ๐ด๐œ๐—จโ„ณ๐ด๐”‘๐‘โ‹ฟ๐ด๐”ป๐ดโ„ฌ๐‘ขฒโ‹ฟ๐•ฑ๐‘ขฒ๐ด๐‘ฎ๏ผด๐œ๏ผฉ๐–ฒ๏ผด๏ผฉโ„ณโ‹ฟ๐ด๐ถ๏ผด๐—จ๐ด๐‘ขฒ๐‘ขฒ๐–ธ๐–ฒโ—‹๐–ธโ—‹๐—จ๐”˜๐”‘โ—‹๐•Ž๐–ธโ—‹๐—จ๐‘ฎโ—‹๏ผด๏ผฉ๏ผด๐‘๏ผฉ๐‘ฎ๐œ๏ผดยป (you need wide Unicode coverage for this to render, probably nothing short of unifont will work,) meaning that the flag that goes into CTFd is flag[stegothree:thisisahumanreadableflagthistimeactuallysoyouknowyougotitright] (note the message at the beginning of the text that states n.b.: the format for this flag is instead flag[something:else] and must be submitted all lowercase and all composed of the characters "abcdefghijklmnopqrstuvwxyz[]:".)

N.B.: One competitor ended up taking a much more labor-intensive route to this once since his browser interpreted the file as Latin-1 or something (this is a Mac thing I think, since I couldn't reproduce it on my (Linux) laptop.) This meant that he had to read through hamlet and figure out which character had been turned into gibberish (usually multiple accented English characters) and add that one to the flag he was accumulating.

Trivia 1

This was a little piece of psuedo-lisp meant to be evaluated on Wikipedia. This isn't actually evaluable in any interpreter other than your brain, so there aren't hard-and-fast rules about what the constructs mean but they are all pretty common in lisp dialects. Here's the expression:

(filter (lambda x (endswith x "98"))
    (ISINs-of (((lambda x (lambda a (x (x (x a))))) parent-company) (car (filter (lambda x (= (operating-country x) 'US))
        (get-publishers (car (filter (lambda x (& (= (year-published x) 1954) (work-type 'book)))
            (works-by (author-of (find-book "Brave New World")))))))))))

Starting with (find-book "Brave New World") we navigate to the article about Brave New World on Wikipedia. Then we follow author-of to Aldous Huxley and from there follow works-by to his works list. The filter (lambda x (& (= (year-published x) 1954) (work-type 'book))) applied to this list restricts it to only books published in 1954. This reduces it to one item, which is taken via car. This is The Doors of Perception. Here we get-publishers and see two options. Applying filter (lambda x (= (operating-country x) 'US)) reduces it to a list of one option, which is taken via car. This is Harper & Row. Next this is called with Harper & Row as it's argument: ((lambda x (lambda a (x (x (x a))))) parent-company) this is a combinator that evaluates to (parent-company (parent-company (parent-company ...))). Reading the article we see that Harper & Row is eventually owned by News Corp. We get the ISINs-of News Corp and then apply the filter-and-car idiom again to find the one ending with 98: US65249B1098.

Trivia 2

This one was less straightforward, but maybe easier. The goal was to find out the model identifier of my old laptop (written as "Louis' old laptop".) I have a relativley large online presence. Knowing that I'm involved in running this CTF and part of ACM, you can find my last name on the ACM UMN Chapter Officers Page. From there googling my full name takes you to this blog, where the identifier is visible in a screenshot that appears in the article I wrote about Laptop Battery Woes: A1029D1102.

Binex 1

This was a binary that, when run on my laptop (with some luck) prints the flag. Here's the source used to produce it:

#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main(int argc, char** argv) {
    char hostname[1024];
    gethostname(hostname, 1024);
    char *username = getlogin();

    if (strcmp(hostname, "bet") != 0) exit(1);
    if (strcmp(username, "louis") != 0) exit(2);

    srand((unsigned int)time(NULL));
    if (rand() != 0) exit(3);

    char magic[] = {32, 43, 42, 57, 33, 39, 41, 59, 45, 43, 74, 0};
    char key[4096];
    sprintf(key, "%s%s%s%s", username, hostname, username, hostname);
    for (int i=0; i<11; i++) magic[i] ^= key[i];

    printf("flag{binex1:%s:%s:%s}", hostname, username, magic);
    return 0;

The hint "It works on my laptop" alludes to the check of the username (louis) and hostname (bet) of my laptop. It would also require some luck to hit the rand() == 0 case. Disassembling this should show the simple comparison between the result of gethostname and getlogin and the strings bet and louis. However since the flag is stored XORed with a string composed of the results of gethostname and getlogin you can't just pull out the flag from strings. The intended attack was to build a dylib and set it as the LD_PRELOAD for the system, causing the functions defined to override the libc functions. You could also look at the sprintf and do the xor yourself.