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:
SendMsg
with some fixed payload valueRecvMsg
, which should be the server’s response to said payloadmain.genRSA
, which unsurprisingly generates a set of RSA constants with the functionmain.genPrime(0x10)
.e
appears to be fixed at0x10001
common.BinaryAppend
, which seems to format the RSA public key and exponentSendWrapped
, which should be sending those constants with some form of AES-CTR encryption as previously establishedRecvWrapped
, which would be the server’s responsecommon.BinaryDecode
, which I’m assuming is some parsing of the server’s responsecommon.(*PayloadCipheredKey).GetKey
, which definitely has to do with setting a new AES key or something- another
SendWrapped
/RecvWrapped
pairing
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
:
b *0x4ed67c
, thenset $rax = 52883
- this breakpoint is at the first call to
genPrime()
ingenRSA()
- this is the first prime factor of our target
n
- this breakpoint is at the first call to
b *0x4ed690
, thenset $rax = 64439
- this breakpoint is at the second call to
genPrime()
ingenRSA()
- this is the second prime factor of our target
n
- this breakpoint is at the second call to
b *0x004edbf9
- this is the call to
GetKey
- from delve, we know that
$rbx
isd
and$rcx
isn
and$rax
is the pointer to the parts - we break here for posterity as we don’t actually need these values since we’ll just get the binary to compute the key for us anyways
- this is the call to
b *0x004edc2c
- this is at the
SendWrapped
following theGetKey
$rax
is the pointer to theConnection
struct, then we inspect the pointer toCryptKey
- this is at the
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!