šŸ“— CTF Writeup

3rd Place in Wargames MY 2024 + Writeup

date
Dec 28, 2024
slug
wgmy24-hotbirdgl4z3rs
author
status
Public
tags
WGMY
HotBirdGl4z3rs
Digital Forensics
Steganography
AI
CTF
summary
During the Wargames MY CTF 2024 held on 28 December, our team HotBirdGl4z3rs secured 3rd place in the open category. This is also my writeup for some challenges that I solved (Forensics + MISC) :D
type
Post
thumbnail
OPEN_WINNERS_WGMY24_V2.jpg
category
šŸ“— CTF Writeup
updatedAt
Dec 29, 2025 06:56 PM
I missed WGMY last year, so I definitely couldnā€™t miss out this year šŸ˜¤ Anyways I joined team HotBirdGl4z3rs and our team won 3rd place out of 266 teams in the open category! šŸ”„
notion image
Ā 
Huge shoutout to my GOAT teammates for carrying fr šŸ’ŖšŸ» (It really looks like the avengers, and Iā€™m the NPC HAHAHA)
+ Wesley
+ Wesley
Ā 
Of course, massive props to the WGMY team for putting together such an epic CTF like this, I actually had a lot of fun doing the challenges, and I learnt some new stuff (accidentally) along the way!
Ā 
Anyways, below is my writeup for some Forensic challenges (My pog teammates solved the other 2 while I was sleeping HAHA) and all Miscellaneous challenges. Enjoy! :D
Table of Contents:

Forensics


I Canā€™t Manipulate People


Every ICMP Packet had a data byte, so you can just extract that with tshark.
notion image
Command used: tshark -r traffic.pcap -Y "icmp" -T fields -e data | xxd -r -p
  • -Y specifies a display filter
  • -r specifies "reverse mode," which tells xxd to convert a hexdump back into its original binary data
  • -p enables "plain hexdump" mode, which means the input hexdump will only include hexadecimal digits, ignoring any formatting (like line numbers or ASCII annotations)
notion image
WGMY{1e3b71d57e466ab71b43c2641a4b34f4}

Oh Man


In the packet capture, there is a large number of SMB2 encrypted packets. Hence, you can apply the smb2 filter to display only SMB2 packets and easily locate NTLM authentication exchanges within the protocol.
Ā 
By examining the packet information, I noticed that one of the session setup request was associated with an Administrator user. I decided to focus on this session and identified the corresponding request and response packet pairing.
notion image
To extract the NTLM response, these information must be obtained from both the response and request packets, and be placed in this order: User name::Domain name:NTLM Server Challenge:NTProofStr:Rest of NTLMv2 Response
  1. Response Packet - NTLM Server Challenge
    1. notion image
  1. Request Packet
    1. notion image
    2. User Name
    3. Domain Name
    4. NTProofStr
    5. Rest of NTLMv2 Response
Ā 
Create a file with the extracted data in the format mentioned:
echo "Administrator::DESKTOP-PMNU0JK:7aaff6ea26301fc3:ae62a57caaa5dd94b68def8fb1c192f3:01010000000000008675779b2e57db01376f686e57504d770000000002001e004400450053004b0054004f0050002d0050004d004e00550030004a004b0001001e004400450053004b0054004f0050002d0050004d004e00550030004a004b0004001e004400450053004b0054004f0050002d0050004d004e00550030004a004b0003001e004400450053004b0054004f0050002d0050004d004e00550030004a004b00070008008675779b2e57db010900280063006900660073002f004400450053004b0054004f0050002d0050004d004e00550030004a004b000000000000000000" > hash.txt
Next, I used Hashcat to crack the NTLMv2 hash: hashcat -m 5600 -a 0 hash.txt /usr/share/wordlists/rockyou.txt After running the command, the cracked password is password<3
Ā 
To decrypt the SMB packets, go to Wireshark > Edit > Preferences > Protocols > NTLMSSP > Put the password in > Apply
notion image
Ā 
Now its decrypted, and you can now view the SMB objects:
notion image
Ā 
By exporting all objects and viewing 5cRxHmEj (cat 5cRxHmEj), this was the content:
The minidump has an invalid signature, restore it running: scripts/restore_signature 20241225_1939.log Done, to get the secretz run: python3 -m pypykatz lsa minidump 20241225_1939.log
These were the instructions to restoring the minidump signature and extract the secret. In order to use scripts/restore_signature, you will need to clone the NanoDump repository and install pypykatz:
git clone https://github.com/fortra/nanodump.git pip3 install pypykatz
Ā 
Then do what was mentioned in the 5cRxHmEj file for the 20241225_1939.log, and the flag is there:
notion image
notion image
wgmy{fbba48bee397414246f864fe4d2925e4}

MISC


DCM Meta


In the given file, you'll find a string: f63acd3b78127c1d7d3e700b55665354
Rearrange it according to the order specified in the challenge description:
input_string = "f63acd3b78127c1d7d3e700b55665354" indices = [25, 10, 0, 3, 17, 19, 23, 27, 4, 13, 20, 8, 24, 21, 31, 15, 7, 29, 6, 1, 9, 30, 22, 5, 28, 18, 26, 11, 2, 14, 16, 12] # Rearrange characters based on indices rearranged_string = ''.join(input_string[i] for i in indices) print("wgmy{"+rearranged_string+"}")
wgmy{51fadeb6cc77504db336850d53623177}

Christmas GIFt


The challenge is a very long GIF, and it can be solved by examining the last frame in Stegsolve's Frame Browser:
notion image
šŸ’”
I thought this was the solution immediately because the same concept was done in a recent CTF (SherpaCTF Web category)
wgmy{1eaa6da7b7f5df6f7c0381c8f23af4d3}

Invisible Ink


  1. Open the GIF in Stegsolve
  1. Stegsolve > Analyze > Frame Browser
  1. Extract both distorted frames since distorted frames normally means somethingā€™s hidden šŸ‘€ (and the flag does seem to be in the middle)
notion image
notion image
  1. Take one of the extracted frames and open it back in Stegsolve, use any Random Colour Map X filter that makes bits of the flag viewable, then save it
  1. Repeat Step 4 for the second frame
  1. Use Stegsolveā€™s Image Combiner (Analyze > Image Combiner) tool to merge the frame from Step 4 and 5 together
Left shows a frame after Step 4/5, Right shows the flag after Step 6
Left shows a frame after Step 4/5, Right shows the flag after Step 6
wgmy{d41d8cd98f00b204e9800998ecf8427e}

Watermarked?


A GIF was given. Honestly I had no idea what to do with it, until a hint dropped about ā€œWatermark Anythingā€.
Ā 
A quick google tells us its this project: https://github.com/facebookresearch/watermark-anything
Ā 
They even had a Hugging Face demo for their PoC, but the watermark detection function didnā€™t work for me. But thankfully, they also provided a Google Colab file, which came to the rescue.
The main focus should be on this part of the code:
# define a 32-bit message to be embedded into the images wm_msg = wam.get_random_msg(1) # [1, 32] print(f"Original message to hide: {msg2str(wm_msg[0])}") # Iterate over each image in the directory for img_ in os.listdir(img_dir)[:num_imgs]: # Load and preprocess the image img_pt = load_img(os.path.join(img_dir, img_)) # [1, 3, H, W] # Embed the watermark message into the image outputs = wam.embed(img_pt, wm_msg) # Create a random mask to watermark only a part of the image mask = create_random_mask(img_pt, num_masks=1, mask_percentage=proportion_masked) # [1, 1, H, W] img_w = outputs['imgs_w'] * mask + img_pt * (1 - mask) # [1, 3, H, W] # Detect the watermark in the watermarked image preds = wam.detect(img_w)["preds"] # [1, 33, 256, 256] mask_preds = F.sigmoid(preds[:, 0, :, :]) # [1, 256, 256], predicted mask bit_preds = preds[:, 1:, :, :] # [1, 32, 256, 256], predicted bits # Predict the embedded message and calculate bit accuracy pred_message = msg_predict_inference(bit_preds, mask_preds).cpu().float() # [1, 32] bit_acc = (pred_message == wm_msg).float().mean().item() # Save the watermarked image and the detection mask mask_preds_res = F.interpolate(mask_preds.unsqueeze(1), size=(img_pt.shape[-2], img_pt.shape[-1]), mode="bilinear", align_corners=False) # [1, 1, H, W] save_image(unnormalize_img(img_w), f"{output_dir}/{img_}_wm.png") save_image(mask_preds_res, f"{output_dir}/{img_}_pred.png") save_image(mask, f"{output_dir}/{img_}_target.png") plot_outputs(img_pt.detach(), img_w.detach(), mask.detach(), mask_preds_res.detach(), labels = None, centroids = None) # Print the predicted message and bit accuracy for each image print(f"Predicted message for image {img_}: {msg2str(pred_message[0])}") print(f"Bit accuracy for image {img_}: {bit_acc:.2f}")
Ā 
There was also a code snippet to edit the parameters. I changed the number of images to 64, and placed all of my images in the assets/images folder, since I want it to run through all 64 frames that I extracted using ezgif (https://ezgif.com/split/ezgif-5-c8b080046714.gif):
# Seed seed = 42 torch.manual_seed(seed) # Parameters img_dir = "assets/images" # Directory containing the original images num_imgs = 64 # Number of images to watermark from the folder proportion_masked = 0.5 # Proportion of the image to be watermarked (0.5 means 50% of the image) # create output folder output_dir = "outputs" os.makedirs(output_dir, exist_ok=True)
Ā 
Then I gave the main program a run, basically what happens is:
  1. A random 32-bit message is generated using wam.get_random_msg(1), which will be embedded into the images (displayed in its string format using msg2str).
  1. Each image in the specified directory (img_dir) is processed, up to a defined limit (changed in the parameter called num_imgs).
  1. (Image preprocessing) The image is loaded and converted into a tensor using the load_img function to prepare it for embedding. The tensor has the shape [1, 3, H, W].
  1. The 32-bit watermark message (wm_msg) is embedded into the image using wam.embed. This creates a watermarked version of the image.
  1. A random mask is generated with a specified percentage (proportion_masked) to ensure only parts of the image are watermarked. The masked watermarked image (img_w) is created by blending the watermarked image and the original image using the mask.
  1. Then the outputs are saved and visualized:
      • The watermarked image (_wm.png)
      • The predicted mask showing detected watermark regions (_pred.png)
      • The target mask used during embedding (_target.png)
  1. Lastly, the predicted message and bit accuracy are printed.
Ā 
I initially tested it using just the first frame, and the predicted message was 01010111011000010111001001100111, which decodes to ā€œWargā€. So I decided to try it with all frames by modifying the code so my output is more readable:
wm_msg = wam.get_random_msg(1) print(f"Original message to hide: {msg2str(wm_msg[0])}") # My frames were in .gif apparently so yea HAHAH image_extensions = (".gif") # Sort the image so that it runs in the order of how my frames were named (1st frame -> 64th frmae) image_files = sorted([f for f in os.listdir(img_dir) if f.lower().endswith(image_extensions)]) # Iterate over each image in the directory, sorted by name for img_ in image_files[:num_imgs]: # Load and preprocess the image img_pt = load_img(os.path.join(img_dir, img_))= # Embed the watermark message into the image outputs = wam.embed(img_pt, wm_msg) # Create a random mask to watermark only a part of the image mask = create_random_mask(img_pt, num_masks=1, mask_percentage=proportion_masked) # [1, 1, H, W] img_w = outputs['imgs_w'] * mask + img_pt * (1 - mask) # [1, 3, H, W] # Detect the watermark in the watermarked image preds = wam.detect(img_w)["preds"] # [1, 33, 256, 256] mask_preds = F.sigmoid(preds[:, 0, :, :]) # [1, 256, 256], predicted mask bit_preds = preds[:, 1:, :, :] # [1, 32, 256, 256], predicted bits # Predict the embedded message pred_message = msg_predict_inference(bit_preds, mask_preds).cpu().float() # [1, 32] print(msg2str(pred_message[0]))
This was the output:
01010111011000010111001001100111 01100001011011010110010000100001 00101110010011010101000100100000 01101001011100110010000001100001 00000000001100100011010000101000 01101000011011110111010001110010 00100000011011110110111001101100 01101001011011000110010000000000 01000011010101000100010000100000 01101000011000010110001101101011 01100001011011000110011000000000 01100111011000010110110101100101 00001110001000000101011101100100 01101100011011000010110000100000 01101001011101000010000001101001 01110011011000000110010100100000 01100011011011010110110101110000 01100101011101000110110101110100 11100011011011010010010000000000 01101111011001100010000001110011 01101111011100100111010001110011 00001110001000000100001101101111 01101110011001010011000001100001 01110100011101010010000001101111 01001110001000000111001101101111 01101100011101100110100001101110 01100111001000000111010001101000 01101001011100110010000001100011 01101000011000010110110001101100 01100101011011100110011101100101 00100001001000000101010001101000 01101001011100010010000001001001 01110011001000000110011001101111 01110010001000000111100100101111 01110101001110100010000001110111 01100111011011010111100101111011 00110010011000110110001100110100 00110111011001000110010000101000 01100110011000100011011000110010 01100011001100100110000100111001 00110010001101110011001100110000 01100001001101000110010000110010 00110101001100100110001000111000 01100100001110010110000100110111 01111101001011100010000001010100 01101000011000010110111001101011 01110011001000000110011001101111 01110010001000000111000001101100 01100001011111010110100100101110 01100111001000000111011101101001 01110100011010000010010001110101 01110011001011100010000001010111 01100101001000000110100001101111 01110000011001010010010001111001 01100111011101010010000001100101 01101110011010100110111101111001 00100000011100110110111101101100 01110110011011010110111000100111 00100000011111010111010000100000 00100000011000110110100001100001 01101100011011000110010001101110 01100111011001010111001100101110 00100000001011010010110100100000 01010111010001110100110101011001
Which turns into this:
Wargamd!.MQ is a 24(hotr onlild CTD hackalf game Wdll, it is`e cmmpetmtĆ£m$ of sorts Cone0atu oN solvhng this challenge! Thiq Is for y/u: wgmy{2cc47dd(fb62c2a92730a4d252b8d9a7}. Thanks for pla}i.g with$us. We hope$ygu enjoy solvmn' }t challdnges. -- WGMY
Gave it another run, and a slightly different output appears:
WaRGame3.MY ic a 24-hoqr OHlinE CA haKij gaL. All hd as competItioH of sorts. CKngatc on klvIng thic chahlenEe! Thhc as Fop Iu: wfmHʒ2cC46dDfb62c2a92732a4d252B8d9a}. ThaHks Kp PlayIng witi Uq. d hGpe yoe AnjMy sKlvinG Otb cHallEnges. WGMY
Ā 
Based on this almost-complete output, I explored a few approaches:
  1. Adjusting the code to only select predicted messages that has a high bit accuracy (So the output is more accurate)
    1. Spoiler alert, it is slow af so I just used the second method
  1. Since we know bits of what the flag is going to look like, make sure that the predicted message matches our intended output (AKA Bruteforcing)
Ā 
Given that the structure of the paragraph remains consistent, the range of where the flag should be is from the 32th frame to the 44th frame. So I only placed those frames in the input folder. After multiple runs, I was confident that frame 32 should correspond to 01110101001110100010000001110111 = u: w.
So I modified my code to check against that:
# Define the target message you want to check against target_message = "01110101001110100010000001110111" def process_images(wm_msg, check_for_target=True): image_extensions = (".gif") image_files = sorted([f for f in os.listdir(img_dir) if f.lower().endswith(image_extensions)]) for idx, img_ in enumerate(image_files[:num_imgs]): # Load and preprocess the image img_pt = load_img(os.path.join(img_dir, img_)) # [1, 3, H, W] # Embed the watermark message into the image outputs = wam.embed(img_pt, wm_msg) # Create a random mask to watermark only a part of the image mask = create_random_mask(img_pt, num_masks=1, mask_percentage=proportion_masked) # [1, 1, H, W] img_w = outputs['imgs_w'] * mask + img_pt * (1 - mask) # [1, 3, H, W] # Detect the watermark in the watermarked image preds = wam.detect(img_w)["preds"] # [1, 33, 256, 256] mask_preds = F.sigmoid(preds[:, 0, :, :]) # [1, 256, 256], predicted mask bit_preds = preds[:, 1:, :, :] # [1, 32, 256, 256], predicted bits # Predict the embedded message and calculate bit accuracy pred_message = msg_predict_inference(bit_preds, mask_preds).cpu().float() # [1, 32] bit_acc = (pred_message == wm_msg).float().mean().item() # Print the predicted message and bit accuracy for each image print(msg2str(pred_message[0])) # Had this as a check for my tries # print(f"Bit accuracy for image {img_}: {bit_acc:.2f}") # Only check the first image for matching target message if check_for_target and msg2str(pred_message[0]) != target_message: print("Predicted message does not match the target. Resetting wm_msg and processing again.") return False return True # Return True if everything is fine # Start with the first watermark message wm_msg = wam.get_random_msg(1) print(f"Original message to hide: {msg2str(wm_msg[0])}") # Process the images with the current wm_msg first_image_check = True while not process_images(wm_msg, check_for_target=first_image_check): # If the message doesn't match, reset wm_msg and retry wm_msg = wam.get_random_msg(1) print(f"Original message to hide: {msg2str(wm_msg[0])}") # After the first check, set the flag to False to stop checking for the target message first_image_check = False
I received various results from different attempts, but knowing the typical length of the flag format (32 characters), I could tell I was VERY close:
wgmy{2cc46dd1fb62c2a92730a4d252b8d9a7}
wgmy{2cc47dd1fb62c2a92730a4d252b8d9a7}
wgmy{2cc47dd8fb62c2a92730a4d252b8d9a7}
wgmy{2cc46df0fb62c2a92732a4d252b8d9a5}
wgmy{2cc46df0fb62c2a92730a4d252b8d9a5}
wgmy{2cc46df0fb62c2!92732a4d252b8d9a7}
wgmy{2cc46df0fb62c2a92732a4d252b8d9a7}
So after multiple runs and carefully matching the consistent sections, I got (bruteforced) the flag!
wgmy{2cc46df0fb62c2a92732a4d252b8d9a7}
šŸ’”
This is definitely not the proper way to do this, I did it this way because I had to rush to somewhere, time is running out :ā€™)) Post Edit: I saw the Team That time I got reincarnated as a CTFā€™s WU on this challenge, and below is my honest reaction:
I did some smooth brain shenanigans fr šŸ¤” Iā€™m sorry zx :ā€™D
I did some smooth brain shenanigans fr šŸ¤” Iā€™m sorry zx :ā€™D