scufFed

x3CTF 2025 (ft. mvm)

4008 words | 19 minutes

x3CTF was a kawaii nya nya *paws at you* CTF which my team slight_smile took part in for funsies and left with an 8th place finish, satisfied with the enjoyable challenges they put up. My main focus for this CTF was solving the rev challenges and that will be the focus of this blogpost.

Fig: A respectable placing

The challenges are available here for your own perusal.

Reverse Engineering

keystore-rs

keystore-rs is supposed to be a hard Rust reverse challenge, but I found it to be the easiest rev challenge of the bunch :p

We just get one file to work with, the eponymous keystore-rs, and interacting with it reveals that it’s a 32 char flag checker.

Fig: Blimey, a flag checker!

Running the binary with gdb exits immediately, which indicates some antidebugging present.

Fig: no debug 4 u

Let’s prode for a usual suspect: ptrace. With catch syscall ptrace in gdb, we run and notice it stops at, well, a ptrace call.

Fig: To catch a ptrace

We bypass this ptrace by stepping forward by one and setting $rax = 0. However, this trips up the program on the next ptrace call. Weirdly enough, leaving the subsequent calls untouched actually allows us to reach the main part of the code. I found an existing blogpost on binaries that exhibit this sort of behaviour and well, let’s just roll with that.

We’ve thus established our first step to allow for debugging, a breakpoint at 0x55555555fa10 followed by setting $rax = 0. Cool.

Let’s throw this binary into binja and see what we’ve got.

Fig: Yuck.

As expected, the decompilation output is not pretty. On top of that, I don’t have the newest version of binja that can produce pseudo-Rust output for, erm, reasons. However, we can cheese our way to finding the “true” main function by inspecting XREFs to strings that we know are in this binary. Something annoying is that Rust strings are not cleanly separated with null bytes and can often be found mashed together in the binary, so we will give binja a hand and define those char arrays for it.

Fig: Before the makeover. Fig: After our makeover.

Let’s trace back the XREFs to see where the string "The key is incorrect!" is referenced.

Fig: Our reference to the string in sub_b9d0

We can see that it is passed into another variable if the check rax_266 == 0 goes through, so that’s probably somewhat related to our key checking. Inspecting sub_1e349, the function that sets rax_266, reveals a bcmp.

Fig: bcmp present in sub_1e349

Let’s place a breakpoint there in gdb and see what we can get!

Fig: Breaking at the bcmp

We see our string input being directly compared to something else. Inspecting the value stored at the memory our input is being compared to reveals the string blahajs_for_the_win_c6e3a9b36269. Put this in as the key into the keystore-rs binary and we get the flag!

Flag: x3c{rust_r3v_paiiiin_:3}

oh-my-gadt

This is a source given Haskell reverse challenge. As someone who has been learning OCaml, I thought this would be tolerable, but this turned out to be one of the more aggravating challenges to deal with simply due to having to wrangle with the Haskell syntax.

The file is heavily obfuscated (challenge file for your reference), so we start off by deobfuscating some of the types and making some of the functions more, erm, distinct.

 1{-# LANGUAGE GADTs, FlexibleInstances, FunctionalDependencies, EmptyDataDeriving #-}
 2module Main(main)where{
 3    import Data.Char(ord);
 4    import Data.Bits(xor,Bits);
 5    import Data.Word(Word8);
 6    import System.IO;
 7
 8    data IIlIll;
 9    data IIlIlI llIIII;
10
11    lIllll::IlIlIl lIIlII->lIIlII;
12    lIllll IlIIll=concat;
13    lIllll IllllI=zipWith;
14    lIllll IIlIIl=take;
15    lIllll IIIIlI=drop;
16    lIllll IIIlIl=length;
17    lIllll IIllII=foldr;
18    lIllll IIlIll=foldl1;
19    lIllll IIIlll=xor;
20    lIllll IlIllI=iterate;
21    lIllll IIIIII=fromIntegral;
22    lIllll IlllIl=ord;
23    data IlIlIl llIIII where{
24        IlIIll::Foldable llIllI=>IlIlIl(llIllI[lIIlII]->[lIIlII]);
25        IllllI::IlIlIl((lIIlII->lllIII->lIlIlI)->[lIIlII]->[lllIII]->[lIlIlI]);
26        IIlIIl::IlIlIl(Int->[lIIlII]->[lIIlII]);IIIIlI::IlIlIl(Int->[lIIlII]->[lIIlII]);
27        IIIlIl::Foldable llIllI=>IlIlIl(llIllI lIIlII->Int);
28        IIllII::Foldable llIllI=>IlIlIl((lIIlII->lllIII->lllIII)->lllIII->llIllI lIIlII->lllIII);
29        IIlIll::Foldable llIllI=>IlIlIl((lIIlII->lIIlII->lIIlII)->llIllI lIIlII->lIIlII);
30        IIIlll::Bits lIIlII=>IlIlIl(lIIlII->lIIlII->lIIlII);
31        IlIllI::IlIlIl((lIIlII->lIIlII)->lIIlII->[lIIlII]);
32        IIIllI::IlIlIl((lIIlII->lllIII)->[lIIlII]->[lllIII]);
33        IIIIII::(Integral lIIlII,Num lllIII)=>IlIlIl(lIIlII->lllIII);
34        IlllIl::IlIlIl(Char->Int)
35    };
36
37    data IlIlII llIIII where{
38        IlIlII::[Word8]->IlIlII llIIII;
39    };
40
41    class IIIlII llIIII llIIlI|llIIII->llIIlI where{
42        a::IlIlII llIIII->IlIlII llIIlI;
43        b::IlIlII llIIII->IlIlII llIIlI;
44        c::IlIlII llIIII->IlIlII llIIlI;
45        d::IlIlII llIIII->IlIlII llIIlI;
46    };
47    
48    instance IIIlII IIlIll(IIlIlI IIlIll) where {
49        d(IlIlII lIIIll) = IlIlII $ concat . zipWith (\lIIIll llIIII-> take (length llIIII)(drop lIIIll((\lIIIll->let llIIII=lIIIll++llIIII in llIIII)llIIII)))[0..] $ (let llIIll _ []=[]; llIIll lllIIl llIIII = take lllIIl llIIII:llIIll lllIIl(drop lllIIl llIIII)in llIIll 4) lIIIll;
50    };
51    
52    instance IIIlII(IIlIlI IIlIll)(IIlIlI(IIlIlI IIlIll))where{
53        c(IlIlII lIIIll) = IlIlII $ map (\x -> 6*x^6+2*x^3+x) lIIIll;
54    };
55    
56    instance IIIlII(IIlIlI(IIlIlI IIlIll))(IIlIlI(IIlIlI(IIlIlI IIlIll)))where{
57        b(IlIlII lIIIll)=IlIlII $ foldr (\a l@(h:_)->xor h a:l) [head lIIIll] (tail lIIIll);
58    };
59    
60    instance IIIlII(IIlIlI(IIlIlI(IIlIlI IIlIll)))IIlIll where{
61        a(IlIlII lIIIll)=IlIlII $ iterate (\lIIIll->tail lIIIll ++ [foldr (xor) 0 lIIIll])lIIIll!!3;
62    };
63    
64    main = putStr "Flag: " >> hFlush stdout >> getLine >>= \lIIlII-> putStrLn  $  (
65         if (2205967053642207131367982253372196254666549571698892523008302353266425115464942068759663110459718064363978392428938015071==) . foldl1 (\x y -> x*256 + y) . map(fromIntegral) . (\(IlIlII lIIIll)->lIIIll) $ (iterate(a . b . c . d)(IlIlII $ map(fromIntegral . ord) lIIlII::IlIlII IIlIll)!!13) 
66         then "That's the flag!" 
67         else "Nope!"
68     )
69}    

This is already a significant step up from the original code. With a bit of LLM assistance (sorry!) we can determine that the flag check at the very end applies the composite function a . b . c . d onto the chars of the input 13 times, then the left fold calculates $\sum 256^na_n$ (by repeatedly applying 256*x + y).

We can thus retrieve the augmented array by dividing by 256 a bunch of times:

[218, 178, 41, 40, 86, 246, 95, 72, 62, 242, 202, 19, 3, 157, 67, 151, 216, 7, 151, 44, 138, 92, 93, 106, 31, 206, 26, 219, 93, 217, 202, 14, 141, 140, 128, 167, 15, 43, 52, 200, 53, 30, 104, 253, 176, 156, 2, 148, 49, 95]

We can’t automatically assume we’re dealing with a 50 char input (since the applied functions may change the length) but like, let’s just ball.

Let’s figure out what each of the functions in the composite function does.

Function d

This is the first function applied, which involves some takes and drops and jazz that I actually can’t be bothered to figure out.

Let’s do this dynamically instead:

1main = do
2    let inputList = [0..64] :: [Word8]
3    let result = (d) (IlIlII inputList ::IlIlII IIlIll)
4    case result of
5        IlIlII output -> print output

This outputs:

[0,1,2,3,5,6,7,4,10,11,8,9,15,12,13,14,16,17,18,19,21,22,23,20,26,27,24,25,31,28,29,30,32,33,34,35,37,38,39,36,42,43,40,41,47,44,45,46,48,49,50,51,53,54,55,52,58,59,56,57,63,60,61,62,64]

Eyeballing, it seems that the values aren’t modified, merely shuffled. Further eyeballing tells us it basically does a circular shift every chunk of four, but since this behvaiour is strictly defined and invariant, we can literally just make an “unshuffle” function.

1d = [0,1,2,3,5,6,7,4,10,11,8,9,15,12,13,14,16,17,18,19,21,22,23,20,26,27,24,25,31,28,29,30,32,33,34,35,37,38,39,36,42,43,40,41,47,44,45,46,48,49,50,51,53,54,55,52,58,59,56,57,63,60,61,62,64]
2
3def inv_d(arr):
4    res = [0 for _ in range(len(arr))]
5    for i in range(len(arr)):
6        res[d[i]] = arr[i]
7    return res

Function c

This one is significantly more readable. It’s just a map that passes the value into the polynomial $f(x) = 6x^6 + 2x^3 + x$. Thankfully, this is a bijective function on the range/domain $[0,255]$, so once again we can just construct an inversion function with a fixed array.

1c = [0, 233, 242, 255, 132, 117, 86, 235, 8, 1, 58, 87, 140, 141, 158, 67, 16, 25, 130, 175, 148, 165, 230, 155, 24, 49, 202, 7, 156, 189, 46, 243, 32, 73, 18, 95, 164, 213, 118, 75, 40, 97, 90, 183, 172, 237, 190, 163, 48, 121, 162, 15, 180, 5, 6, 251, 56, 145, 234, 103, 188, 29, 78, 83, 64, 169, 50, 191, 196, 53, 150, 171, 72, 193, 122, 23, 204, 77, 222, 3, 80, 217, 194, 111, 212, 101, 38, 91, 88, 241, 10, 199, 220, 125, 110, 179, 96, 9, 82, 31, 228, 149, 182, 11, 104, 33, 154, 119, 236, 173, 254, 99, 112, 57, 226, 207, 244, 197, 70, 187, 120, 81, 42, 39, 252, 221, 142, 19, 128, 105, 114, 127, 4, 245, 214, 107, 136, 129, 186, 215, 12, 13, 30, 195, 144, 153, 2, 47, 20, 37, 102, 27, 152, 177, 74, 135, 28, 61, 174, 115, 160, 201, 146, 223, 36, 85, 246, 203, 168, 225, 218, 55, 44, 109, 62, 35, 176, 249, 34, 143, 52, 133, 134, 123, 184, 17, 106, 231, 60, 157, 206, 211, 192, 41, 178, 63, 68, 181, 22, 43, 200, 65, 250, 151, 76, 205, 94, 131, 208, 89, 66, 239, 84, 229, 166, 219, 216, 113, 138, 71, 92, 253, 238, 51, 224, 137, 210, 159, 100, 21, 54, 139, 232, 161, 26, 247, 108, 45, 126, 227, 240, 185, 98, 79, 116, 69, 198, 59, 248, 209, 170, 167, 124, 93, 14, 147]
2
3def inv_c(arr):
4    return [c[i] for i in arr]

I’m pretty lazy, huh.

Function b

This is a right fold with a pattern match and an XOR and honestly I just eyeballed this one as well xDD

before b -> [9,146,79,132,54,27,8,53]
after b ->  [64,210,157,25,47,52,60,9]

Knowing that there’s an XOR and that tha head becomes the tail, notice that 60 = 53 ^ 9, 52 = 60 ^ 8 and so on. So it’s just an accumulated XOR. The inverse is, well, just applying the XOR again.

1def inv_b(arr):
2    x = arr[-1]
3    res = [0 for _ in range(len(arr))]
4    res[0] = x
5    cum = x
6    for i in range(len(arr)-2, -1, -1):
7        res[i+1] = arr[i] ^ cum
8        cum = arr[i]
9    return res

Function a

Lastly, this function is also an accumulated XOR by a right fold but applied thrice. Observing this behaviour shows that you’ll just end up with an array cycled right by three and the third element being an XOR of all elements sandwiched.

We just invert these operations by slicing the array and reordering, then undoing the XOR.

1def inv_a(arr):
2    a = 0
3    for _ in arr: a ^= _
4    return arr[-2:] + [a] + arr[:-3]

Final result

We apply these inverse transformations 13 times:

1fin = [218, 178, 41, 40, 86, 246, 95, 72, 62, 242, 202, 19, 3, 157, 67, 151, 216, 7, 151, 44, 138, 92, 93, 106, 31, 206, 26, 219, 93, 217, 202, 14, 141, 140, 128, 167, 15, 43, 52, 200, 53, 30, 104, 253, 176, 156, 2, 148, 49, 95]
2
3for i in range(13):
4    fin = inv_d(inv_c(inv_b(inv_a(fin))))
5
6print("".join([chr(x) for x in fin]))

And we get our flag!

Flag: MVM{::333:3:/::33::33/w0w_g4d75_n_ph4n70m5_y1pp33}

net-msg1

This is part one of a two part Golang chall. Golang sucks to reverse, but thankfully this binary is not stripped and there’s plenty of symbols to work with to make the reversing process tolerable. This, however, is a double edged sword; gdb really doesn’t play nice when there’s supposed to be a source file referenced in the debug symbols table and it likes to segfault on breakpoints, which means I worked with a stripped binary for some of the reversing. I eventually figured out I could use delve, which is Golang’s native debugger, but it comes with its own caveats too (which we’ll see later).

The challenge distinctly requires remote interaction with a server that we do not have the spec for, so let’s just play around and see what’s going on.

Using the credentials they give us, we’re given a list of actions we can perform.

| | | |

Fig: Interacting with the server

Okay, cool. Goal for this chall is obvious, somehow “re-enable” the flag fetch action.

Let’s see how the client actually interacts with the server by throwing it into Ghidra and using GolangAnalyzer. Stepping into main.main and staring for a bit, we find the code that’s run when you go for option f.

Fig: and I oop

Looks like the flag fetching functionality is disabled client side :p

Looking at the other options, we notice a pattern of creating some payload with BinaryAppend (or not), then sending a message with SendWrapped and receiving a response with RecvWrapped.

Fig: Example send/receive pattern for the mailbox request

Stepping into the decompilation for SendWrapped, thanks to all the debug symbols we can somewhat discern what all the parameters are.

Fig: Ghidra output for SendWrapped

The second parameter is a MsgType, and looking at the other calls with it we can see that this value is varied for each request type.

m -> 0x6
r -> 0x9
s -> 0xb
q -> 0xe

Instead of patching, I felt it’d be best to just place a breakpoint at a SendWrapped call and replace the MsgType, that way we can avoid any response format validation by just breaking on the response too. The easiest one to deal with would probably be the mailbox request as there’s no payload, and we place a breakpoint right after the RecvWrapped call at 0x004ee677.

With some guess and check, we settle on 0x8 as the correct message type.

Fig: Registers at the RecvWrapped breakpoint

We find that $rcx contains a pointer to the server response, which in this case is just our flag.

Flag: x3c{h1dd3n_funct1on4lity_w00t_w00t}

net-msg2

This is where things get convoluted. We’re given a pcap file which contains a client/server interaction. The goal is to decrypt the interaction and get the flag, somehow.

This is where one of the great inconveniences of reversing Golang comes into play: the calling and return convention is pretty whacky and thus far my experience with it (at least in Ghidra) has been subpar. We can use Ghidra to get an outline of things, but it’s still going to come down to debugging to get things right.

It goes without saying that our send and receive functions must contain some encryption/decryption routine, so we quickly inspect that.

Fig: Nooooticing

There’s an EncryptPayload function that’s called before SengMsg. Stepping into it, we see the functions crypto/md5.(*digest).Write, crypto/md5.(*digest).checkSum, crypto/aes.NewCipher, and crypto/cipher.NewCTR being called in that order. We can thus discern that there’s some AES key being created, an IV for AES-CTR being set, and the output of the MD5 checksum function probably has something to do with it. We’ll keep these in mind.

Inspecting the main.connect function, we get a better sense of how the binary establishes a connection with a server.

After performing a net.Dial to get the IP address of the provided hostname, the following functions are called in this order:

I’m going to assume the AES key used to encrypt the RSA constants is probably drawn from the server’s response to the initial payload, which brings us to an idea: what if we just reuse the responses from the given pcap file?

Analysing the pcap file, we’ll just copy the first two responses. The third appears to be some sort of echo (perhaps a handshake?) so I’m just going to make our fake server reply with what it receives. Eyeball analysis also tells us that the 2nd and 3rd bytes from the first response become some sort of “marker” that is present in all later messages, so we’ll keep note of those.

 1import socket
 2from Crypto.Util.number import long_to_bytes
 3import signal
 4import sys
 5
 6class GracefulServer:
 7    def __init__(self, host='localhost', port=8888):
 8        self.host = host
 9        self.port = port
10        self.server_socket = None
11
12    def setup_server(self):
13        self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14        self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15        self.server_socket.bind((self.host, self.port))
16        self.server_socket.listen(1)
17        print(f"Server listening on {self.host}:{self.port}")
18
19    def handle_shutdown(self, signum, frame):
20        print("\nShutting down server...")
21        if self.server_socket:
22            self.server_socket.close()
23        sys.exit(0)
24
25    def run(self):
26        # Register signal handler for Ctrl+C
27        signal.signal(signal.SIGINT, self.handle_shutdown)
28
29        self.setup_server()
30        
31        while True:
32            client_socket, address = self.server_socket.accept()
33            print(f"Connection from {address}")
34            
35            try:
36                # First receive
37                data = client_socket.recv(1024)
38                print(f"Received bytes: {data}")
39                
40                # First fixed response
41                response1 = long_to_bytes(0x01c8bf0000b7da)
42                client_socket.send(response1)
43                
44                # Second receive
45                data2 = client_socket.recv(1024)
46                print(f"Received second bytes: {data2}")
47                
48                # Second fixed response
49                response2 = long_to_bytes(0x02c8bf270094736d1aaa024d1d86931d34f24db7f10b75eedd00e4c4b311dba0c1583ee6e66288809c3fcb0a042f)
50                client_socket.send(response2)
51
52                # Third receive
53                data3 = client_socket.recv(1024)
54                print(f"Received third bytes: {data3}")
55                client_socket.send(data3)
56            
57            except Exception as e:
58                print(f"Error: {e}")
59            
60            finally:
61                client_socket.close()
62
63if __name__ == "__main__":
64    server = GracefulServer()
65    server.run()

With all this in place, we can start placing an obscene number of breakpoints. We end up using delve to help with the debugging as it’s able to more prettily print out the inputs to our functions.

We decide to break on all the crypto functions from above, EncryptMessage, SendWrapped/RecvWrapped and the GetKey functions for they’re the most interesting. Since we’ve got the debugging symbols, this is as simple as typing b crypto/cipher.NewCTR into dlv. A lot of the commands are actually quite gdb like, so we hit r to run and c to continue until we hit our breakpoints.

We break at common.EncryptPayload(), and we can print the arguments to the function with args -v. We see reference to some key and we can actually see the message payload as well. We can inspect the memory that an argument is pointing to (which in this case, m = ("*x3c/common.Msg") at 0xc00011ba68) using x -fmt hex -count [n] -size [m] [addr].

Fig: Breaking at common.EncryptPayload()

Hitting our breakpoint for md5.digest, we also print the arguments to the function, then inspect the memory the first argument is pointing to with x -fmt hex -count 20 -size 8 0xc00011b968 and notice a fixed string that we can see in the Ghidra output and the “marker” bytes as discussed earlier (which in this case are 0xc8bf).

We can confirm that those bytes are in fact the marker bytes since changing the fake server’s response changes these bytes too.

Fig: The fixed bytes in the Ghidra output
Fig: Inspecting arguments with dlv

So far so good! The breakpoint for aes.NewCipher() reveals [17,74,91,199,12,172,213,140,165,77,112,196,121,122,237,19] as the initial AES key, which appears to be invariant. Breaking on cipher.NewCTR() however reveals that there is some computed IV that changes based on the server’s response. By placing an additional breakpoint after the md5.digest, we find that this IV value is actually the md5 hash of the fixed string concatenated with the marker bytes.

Fig: Observing the AES-CTR IV

We can actually use this to decrypt the first few parts of the pcap, which just reaffirms our assumption that the public key and exponent are being sent to the server. (Note: when decrypting we shave off the first few bytes, which as previously discussed is some header with the “marker” bytes). Now we know that in the pcap provided, the value of n used is 3407727637, which thanks to factordb we know $3407727637 = 52883 \times 64439$. The server then responds with what appears to be gibberish.

What’s convenient is that these values seem to be unaffected by main.genRSA(), which means that they’re likely the same for the communications found in the pcap. Where things deviate is at the GetKey call.

Fig: Observing the args for common.(*PayloadCipheredKey).GetKey

Placing a breakpoint after main.genRSA() shows us that n is the public key that’s computed, and it’s really small so we can probably just factordb it. Confusingly, d doesn’t seem to be the private key?? I don’t know if there’s some fuckery because it’s 32-bit RSA but I couldn’t for the life of me calculate the d value present given n. This aside, the values in Parts comes from the gibberish from earlier (we can verify this by testing against remote), so that means our server spoofing should suffice for this too.

Let’s carry on for now.

The next time we break at EncryptPayload(), we notice that the key value has changed! This key value is also passed into SendWrapped as the CryptKey.

Fig: The CryptKey

It remains the same for every other break, so we can assume that this key value has been modified after, well the GetKey call. Since the only thing changing between runs with our spoofed server now is the result of genRSA() and thus the n and d parameters for GetKey, if we’re able to change those values, we don’t even have to bother with reversing GetKey, we can simply… get the key.

So now what we’re stuck with is getting n and d correct. What’s really annoying about delve is that we can’t change register or memory values for some reason?? Unless you have a register with a pointer to some memory location?? The list of restrictions is here and it’s honestly baffling. This makes things a lot more inconvenient, but we still have a way.

Instead of using delve to finish up this challenge, we instead go back to gdb and work with a stripped binary so it doesn’t fucking crash all the time. With delve, we can still determine the memory layout of arguments as well as which registers stores pointers to what, so that we can cross reference in gdb.

By breaking at SendWrapped, and inspecting the memory at the first argument (the Connection struct), we can locate the CryptKey memory location.

Fig: The location of CryptKey

Great! So now we follow through with the following breakpoints in gdb:

This gives us a key of [0x30, 0xe5, 0x18, 0x8a, 0xa7, 0xf4, 0x26, 0x2e, 0xd6, 0x35, 0x07, 0x8a, 0x32, 0x5f, 0x33, 0x3d]!

Now we can pass in the key and IV into a python script and decrypt the messages in the pcap using the generated key:

 1from Crypto.Cipher import AES
 2from Crypto.Util import Counter
 3from Crypto.Util.number import long_to_bytes, bytes_to_long
 4
 5key = bytes([0x30, 0xe5, 0x18, 0x8a, 0xa7, 0xf4, 0x26, 0x2e, 0xd6, 0x35, 0x07, 0x8a, 0x32, 0x5f, 0x33, 0x3d])
 6iv = bytes([208,42,52,109,104,222,212,89,82,154,36,128,199,208,232,222])
 7
 8ctr = Counter.new(128, initial_value=int.from_bytes(iv, 'big'))
 9
10cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
11print((cipher.decrypt(b'\x7fAlb\xe4\xc1\xcf\xe4\xe6')))
12
13cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
14print((cipher.decrypt(b'{AlB\xe4\x11z\xe9]\x02\x1d\x9a5\xc2\xdd=\xf0\xa8\x05*\xe3\x01\xf4?]}\x98k\xcd\xd8#,\x8dq\x91\x1cPY\xec&\x1e')))
15
16cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
17print((cipher.decrypt(b'xAlb\xe4\x1d\xff\xe4\xe6')))
18
19cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
20print((cipher.decrypt(b'yAle\xe4\x10y\xe7Xm\x08\xbf^\x87]E')))
21
22cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
23print((cipher.decrypt(b'wAl`\xe4\x11oj-\xaa\xa8')))
24
25cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
26print((cipher.decrypt(b'tAlx\xe4\x1ez\xf6ZG\x16\xaa3\x97\xca \xe1\xe5\x06F\x82W\xbeY%m\xa54\x98\x89<\x95ok\xeb')))

We get the following:

b'\x01\xc8\xbf\x00\x00\xb7\xdab\xd9'
b'\x05\xc8\xbf \x00goober_supreme\x00\x001jWXdR0uk62f\x00\x00\x00\x00{\xa4G\xcb'
b'\x06\xc8\xbf\x00\x00k\xeab\xd9'
b'\x07\xc8\xbf\x07\x00flag\ngz\x180\xf0\n'
b'\t\xc8\xbf\x02\x00gz\xec\x12\xcd\xc7'
b'\n\xc8\xbf\x1a\x00hope you got flag 1 too :)\x8b\x84\x1az'

We thus get the credentials goober_supreme/1jWXdR0uk62f. Using this to login to the remote and view the mailbox gets us the flag!

Flag: x3c{3l1t3_crypt0_0nly_cough_cough_clickplc}

Ending Remarks

x3ctf was really fun (and really uwu kawaii *paws at you paws at you*) and we were lowkey surprised with the quality of challenges put up. Some of the challs that we didn’t solve were a little bit guessy and silly (mainly the misc), but besides that the rest of the challenges were plenty of fun and well worth the time to work on.

Check out the x3ctf Twitter and show them some love!