Post

WHY2025 CTF Write-up

Full write-up of completed challenges in WHY2025 CTF.

WHY2025 CTF Write-up

WHY2025 CTF Write-up

Author: AppleTree
Team: APISec Avengers
Event Date: Fri 08 Aug 2025 10:00 MDT – Mon 11 Aug 2025 10:00 MDT
Platform: WHY2025 CTF
Flag Format: flag{md5}


✅ Completed Challenges Overview

Challenge NameCategoryPointsStatus
Substitute TeacherCrypto50
ToShredsYouSayForensics100
The WizardForensics100
Painted BlackForensics100
IOT BreachForensics200
Twenty Three DriversMisc50
Ransomware AttackNetwork50
Scan MeNetwork50
Lazy Code 1.0Reversing100
Lazy Code 2.0Reversing100
3 Ball MarkReversing100
WHY2025 CTF TIMESWeb50
PlanetsWeb50
BusterWeb50
Shoe Shop 1.0Web50

🏆 Solved Challenges

Crypto

Challenge: Substitute Teacher

Points: 50
Flag: flag{f3e6a13e6f29cc9a08c2b8829d6359fd}

Description:
A .tar.gz archive contained story.txt, which looked like nonsense words but followed English letter frequency and spacing patterns — a sign of monoalphabetic substitution.

Steps Taken:

  1. Extracted archive:
    1
    2
    
    tar -xvzf substitute_teacher.tar.gz
    cat story.txt
    
  2. Noted common English-like structure:

    • “bpmc” appeared often — could be “this” or “that”.
    • Proper nouns appeared capitalized.
  3. Ran frequency analysis on the text and compared it to English letter frequency.
  4. Guessed letter mappings for common words and proper nouns.
  5. Used quipqiup.com to auto-solve remaining letters by providing partial mappings.
  6. The decrypted text ended with the flag.

Forensics

Challenge: ToShredsYouSay

Points: 100 Flag: flag{dd0755b73e4b7dfd0e06f927874e1511}

Steps Taken:

  1. Extracted Provided File
    Downloaded and extracted the toshredsousay.tgz archive, revealing a PDF containing a scanned image of shredded paper.

  2. Opened the PDF and Took a Screenshot
    Opened the ToShredsYouSay-1.pdf file in a PDF viewer, then captured the shredded paper image for editing.

  3. Manual Reconstruction in Paint
    • Opened the screenshot in Microsoft Paint.
    • Cut each shred into separate pieces.
    • Rearranged the strips manually by aligning text and matching the colored logo portions.
    • Verified the text flow across strips until the entire string was coherent.
  4. Reading the Flag
    Once the strips were properly aligned, the full flag was clearly visible.

Challenge: The Wizard

Points: 100 Flag: flag{7d05b40d5d21d7a69bbdc04f38f9d937}

Steps Taken:

  1. Extract the provided archive
    1
    
    tar -xvzf thewizard.tgz
    

This revealed two key files:

  • .viminfo
  • .pwfault
  1. Review .viminfo for clues Open .viminfo in a text viewer and look for Command Line History entries:

    1
    2
    3
    4
    5
    6
    
    :v/?/d
    :%s,[^a-f0-9],,g
    :%s/\n//
    :s/dd/
    :%s/^...../flag{/
    :%s/.$/}/
    

    These show the transformation steps used in Vim.

  2. Understand the transformations

    • :v/?/d — delete lines not matching ? (likely irrelevant here).
    • :%s,[^a-f0-9],,g — strip all characters except lowercase hex digits.
    • :%s/\n// — remove newlines.
    • :s/dd/ — remove the substring dd.
    • :%s/^...../flag{/ — replace the first five characters with flag{.
    • :%s/.$/}/ — replace the last character with }.
  3. Inspect .pwfault Contents were ASCII gibberish, consistent with needing to be filtered down to hex digits.

  4. Reproduce transformations with shell commands

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    # Step 1 & 2: Remove all non-hex characters
    cat .pwfault | tr -d '\n' | sed 's/[^a-f0-9]//g' > clean_hex.txt
    
    # Step 3: Remove 'dd' substrings
    sed -i 's/dd//g' clean_hex.txt
    
    # Step 4: Replace first 5 characters with 'flag{'
    sed -i 's/^...../flag{/' clean_hex.txt
    
    # Step 5: Replace last character with '}'
    sed -i 's/.$/}/' clean_hex.txt
    
  5. View the final output

    1
    
    cat clean_hex.txt
    

    Output:

    1
    
    flag{a0f6e83e1f18d04c1f9383a30751168e}
    

Challenge: Painted Black

Points: 100 Flag: flag{9fa5f985845fb48d1d1b511882c4b6f4}

Steps Taken:

  1. Initial File Inspection
    • Received case-2025-0412-public.docm and a screenshot of the “redacted” search warrant.
    • Recognized .docm as a ZIP archive containing WordprocessingML and potential VBA macros.
  2. Extract DOCM Contents
    1
    
    unzip case-2025-0412-public.docm -d docm_extracted
    
  • Key extracted files:

    • word/document.xml → main document body
    • word/vbaProject.bin → macro code
    • docProps/core.xml → metadata (author/username)
  1. Recover Visible + Hidden Text

    • Parsed document.xml to extract <w:t> tag contents in order.
    • Found that “redacted” fields still existed as obfuscated strings:

      1
      2
      3
      4
      5
      
      Swywy}wcm3U`}a{l2Hlpf`rm
      A{nfu{>Yi}}j{ev
      %#:2Iaqgdy~qdf-Sll25Zrlizu`b}q%>[P3.<#/
      q~luaj*-:"#j!rs4v,k| <u-9'$wity8$9!.q
      X~ddsh>Gm}idu`
      
  2. Macro Analysis with olevba

    1
    
    olevba case-2025-0412-public.docm
    
    • Found macro Sub PaintItBlack():

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      
      Sub PaintItBlack()
          Dim selectedText As String
          Dim encryptedText As String
          Dim key As String
          Dim xorChar, keyPos
      
          selectedText = Selection.Text
          key = Replace(LCase(Application.UserName), " ", "")
          encryptedText = ""
      
          For i = 1 To Len(selectedText)
              keyPos = i Mod Len(key) + 1
              xorChar = Asc(Mid(selectedText, i, 1)) _
                        Xor Asc(Mid(key, keyPos, 1)) _
                        Xor &H7B
              encryptedText = encryptedText & Chr(xorChar)
          Next i
      
          Selection.Text = encryptedText
          Selection.Shading.BackgroundPatternColor = wdColorBlack
          Selection.Font.ColorIndex = wdBlack
      End Sub
      
  3. Determine XOR Key

    • From docProps/core.xml:

      1
      
      <dc:creator>Olivia Renshaw</dc:creator>
      
    • Macro lowercases & strips spaces:

      1
      
      oliviarenshaw
      
  4. Understand Encryption Scheme

    • Encryption:

      1
      
      E[i] = P[i] ^ key[(i mod len(key))] ^ 0x7B
      

      (with i starting at 1 in VBA)

    • Decryption:

      1
      
      P[i] = E[i] ^ key[(i mod len(key))] ^ 0x7B
      

      → In 0-based languages, emulate the 1-based offset with (i+1) % len(key).

  5. Locate Ciphertext in XML

    • Could not reliably match pasted obfuscated strings due to entity encoding.
    • Instead, located them contextually in document.xml by markers:

      • After "Issued By:"
      • After "Suspect:"
      • After "Address:"
      • After "labeled"
      • After "Detective "
    • Extracted the <w:t> sequences immediately following these markers.
  6. Decrypt with Correct Alignment

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    key = b"oliviarenshaw"
    
    def decrypt(ciphertext: bytes) -> str:
        out = bytearray()
        for i, b in enumerate(ciphertext):
            k = key[(i+1) % len(key)]  # +1 to match VBA 1-based indexing
            out.append(b ^ k ^ 0x7B)
        return out.decode("latin1")
    
    # Example usage:
    print(decrypt(b"Swywy}wcm3U`}a{l2Hlpf`rm"))
    
    • Applied to each extracted encrypted field.
  7. Recovered Plaintext

    • Issued By: Detective Olivia Renshaw
    • Suspect: Victor Langford
    • Address: 217 Shadowcrest Ave, Ravenbrook, NX 4078
    • Device Label: flag{c48219f5ea9d6bb54f7533edfc1a1124}
    • Detective: Olivia Renshaw

Challenge: IOT Breach

Points: 200 Flag: flag{12ff3760f1aef727ac0aa998df310fa5}

Steps Taken:

  1. Extracted IoT device filesystem dump.
  2. Found encrypted file and Perl script enc.pl implementing AES in CBC mode.
  3. Found encryption key in process memory:

    1
    
    L0s3@llYourF1l3s
    
  4. Wrote Python decryption script with pycryptodome:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    from Crypt.Cipher import AES
    import sys
    
    key = b'L0s3@llYourF1l3s'
    with open(sys.argv[1], 'rb') as f:
        data = f.read()
    
    cipher = AES.new(key, AES.MODE_CBC, iv=data[:16])
    plaintext = cipher.decrypt(data[16:])
    print(plaintext)
    
  5. Decrypted the encrypted file — it contained the flag.

Misc

Challenge: Twenty Three Drivers

Points: 50 Flag: flag{23df2w53ac6c4b8a6bfc1cdd769a8a5c}

Steps Taken:

  1. Tested POST request to main site:

    1
    
    curl -X POST https://23drivers.ctf.zone/ -d "secret_code=test"
    
  2. Used ffuf to brute-force:

    1
    2
    3
    4
    5
    
    ffuf -u https://23drivers.ctf.zone/ -X POST \
         -d "secret_code=FUZZ" \
         -H "Content-Type: application/x-www-form-urlencoded" \
         -w codes.txt \
         -mc all -fr "Unknown code"
    
  3. Found valid code 23DF2W which returned the flag in the response.


Network

Challenge: Ransomware Attack

Points: 50 Flag: flag{8f0b879b18c40ddc2f49f73c57d1e6aa}

Steps Taken:

  1. Extract the provided archive
    • We were given ransomwareattack.tgz.
    • Extracted it using:
      1
      
      tar -xvzf ransomwareattack.tgz
      
    • This revealed ransomware-attack.pcap.
  2. Open the packet capture
    • Opened ransomware-attack.pcap in Wireshark.
    • Searched for FTP traffic by applying the filter:
      1
      
      ftp or ftp-data
      
    • Found FTP-DATA streams containing file transfers.
  3. Export files from the PCAP
    • Right-clicked the FTP-DATA stream → “Follow” → “TCP Stream”.
    • Changed the output format to Raw and saved files.
    • Extracted two files:
      • encryptur.py
      • important_file.txt.encrypted
  4. Analyze the ransomware script
    • Opened encryptur.py:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      
      alphabet = 'abcdefghijklmnopqrstuvwxyz'
      
      def shift_chars(text, pos):
          out = ""
          for letter in text:
              if letter in alphabet:
                  letter_pos = (alphabet.find(letter) + pos) % 26
                  new_letter = alphabet[letter_pos]
                  out += new_letter
              else:
                  out += letter
          return out
      
      def encrypt_text(text):
          counter = 0
          encrypted_text = ""
          for i in range(0, len(text), 10):
              counter = (counter + 1) % 26
              encrypted_text += shift_chars(text[i:i+10], counter)
          return encrypted_text
      
    • Determined that the encryption:
      • Works in 10-character blocks.
      • Increments the Caesar shift position by 1 each block.
      • Only lowercase letters are shifted; other characters remain unchanged.
  5. Plan the decryption
    • Reverse the process by:
      • Processing in 10-character blocks.
      • For each block n (starting from 1), shift backward by n % 26.
  6. Write the decryption script
    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
    26
    27
    28
    29
    30
    31
    32
    33
    
    alphabet = 'abcdefghijklmnopqrstuvwxyz'
    
    def shift_chars_reverse(text, pos):
        """Shift characters backwards by pos in the alphabet."""
        out = ""
        for letter in text:
            if letter in alphabet:
                letter_pos = (alphabet.find(letter) - pos) % 26
                out += alphabet[letter_pos]
            else:
                out += letter
        return out
    
    def decrypt_text(text):
        counter = 0
        decrypted_text = ""
        for i in range(0, len(text), 10):
            counter = (counter + 1) % 26
            decrypted_text += shift_chars_reverse(text[i:i+10], counter)
        return decrypted_text
    
    # Load encrypted data
    with open("important_file.txt.encrypted", "r") as f:
        encrypted_data = f.read()
    
    # Decrypt
    decrypted_data = decrypt_text(encrypted_data)
    
    # Save output
    with open("important_file.txt", "w") as f:
        f.write(decrypted_data)
    
    print(decrypted_data)
    
  7. Run the decryption

    • Saved the script as decrypt.py.
    • Executed:

      1
      
      python3 decrypt.py
      
    • Output revealed a Caesar Salad recipe with the flag embedded:

      1
      
      a drizzle of flag{ad1c53bf1e00a9239d29edaadcda2964}
      

Challenge: Scan Me

Points: 50
Flag: flag{c7d7a0db8ad6d228cbf8c77eab1b0baf}

Steps Taken:

  1. Ran a full TCP port scan against the target to reveal all open ports:
    1
    
    nmap -p- -Pn -n --open -vvv -T5 scanme.ctf.zone
    
  • -p- scans all 65,535 ports.
  • --open shows only ports with a service listening.
  • -Pn skips host discovery (treats host as online).
  • -n disables DNS resolution for speed.
  • -T5 sets max timing for fastest scanning.
  1. The scan output showed a large number of unusual open ports, each with a short banner string.

    • Example:

      1
      2
      3
      4
      5
      
      2454/tcp open  unknown
      | banner: f
      3871/tcp open  unknown
      | banner: l
      ...
      
    • Each banner contained a single character or small fragment.

  2. Collected all banner outputs into a file in the order they appeared in the scan (by port number).

  3. Discovered that each banner fragment represented part of the flag text but out of logical sequence if read directly. The intended sequence was ascending port order.

  4. Wrote a quick parsing command to extract the banner text and order it by port number:

    1
    2
    3
    4
    5
    
    nmap -p- -Pn -n --open scanme.ctf.zone -sV \
      | awk '/open/{port=$1} /banner/{print port, $3}' \
      | sort -n \
      | cut -d' ' -f2 \
      | tr -d '\n'
    
    • awk captures the port number and the banner text.
    • sort -n orders results numerically by port.
    • cut and tr join fragments into a continuous string.
  5. The concatenated output formed the complete flag string in flag{md5} format.


Reversing

Challenge: Lazy Code 1.0

Points: 100 Flag: flag{c960f55554871feedd7c33a20fb73919}

Steps Taken:

  1. Loaded the binary into radare2: r2 -w lazy-code-1.0 (-w enables write mode so we can patch instructions.)
  2. Used analysis commands to find the sleep function calls inside the main loop:

    1
    2
    
    aaa              ; analyze all  
    /c sleep         ; search for calls to sleep  
    
  3. Identified the address of the call sym.imp.sleep instruction.
  4. Used s <address> to seek to that location.
  5. Patched the call instruction to NOPs:
    1
    
    wx 9090909090
    

    (number of NOPs depends on the original instruction size).

  6. Saved and exited: qy to save changes.
  7. Ran the patched binary — loop executed instantly, flag printed without delay.

Challenge: Lazy Code 2.0

Points: 100 Flag: flag{b3fda416daebdef7a5d79deb07c43375}

Steps Taken:

  1. Opened the new binary in radare2 with write mode: r2 -w lazy-code-2.0
  2. Repeated analysis to locate the new sleep calls:

    1
    2
    
    aaa  
    /c sleep
    
  3. Found the updated instruction addresses (different from 1.0).
  4. Sought to each call sym.imp.sleep location and replaced with NOPs:

    1
    2
    
    s <address>  
    wx 9090909090
    

    (number of NOPs depends on the original instruction size).

  5. Saved and quit, then executed the patched binary.
  6. The program completed instantly and displayed the flag.

Challenge: 3 Ball Mark

Points: 100 Flag: flag{5c0b2b6b2a4c63f0f3f416d89377dba1}

Steps Taken:

  1. Reverse-engineered binary game in Ghidra.
  2. Found RNG seed initialization and ball position function.
  3. Wrote Python script replicating RNG to predict correct ball for all 10 rounds.
    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
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
     def msvcrt_step(seed):
         # MSVCRT LCG step: returns new seed and rand() value
         seed = (seed * 214013 + 2531011) & 0xFFFFFFFF
         return seed, (seed >> 16) & 0x7fff
    
     bag = ["Blue", "Blue", "Yellow"]  # initial global state from the binary
     seed = 0                          # srand(0)
    
     sequence = []
     print("Simulating 10 rounds:\n")
     for round_num in range(1, 11):
         # shuffle_bag(): three swaps, each with j = rand() % 3
         for i in range(3):
             seed, r = msvcrt_step(seed)
             j = r % 3
             bag[i], bag[j] = bag[j], bag[i]
    
         pick = bag.index("Yellow") + 1
         sequence.append(pick)
         print(f"Round {round_num}: {bag} -> Yellow at position {pick}")
    
     print("\nWinning inputs:", sequence)
     # Output:
         # Simulating 10 rounds:
         # Round 1: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3
         # Round 2: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3
         # Round 3: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3
         # Round 4: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3
         # Round 5: ['Yellow', 'Blue', 'Blue'] -> Yellow at position 1
         # Round 6: ['Yellow', 'Blue', 'Blue'] -> Yellow at position 1
         # Round 7: ['Blue', 'Blue', 'Yellow'] -> Yellow at position 3
         # Round 8: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2
         # Round 9: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2
         # Round 10: ['Blue', 'Yellow', 'Blue'] -> Yellow at position 2
         # 
         # Winning inputs: [3, 3, 3, 3, 1, 1, 3, 2, 2, 2]
    
  4. Fed predicted choices to binary — received flag.

Web

Challenge: WHY2025 CTF TIMES

Points: 50 Flag: flag{4dc8760b6a0c12bb15b4c77102cf76f6}

Steps Taken:

  1. Downloaded obfuscated paywall.min.js.
  2. Decompiled JavaScript, found logic to hide full article text.
  3. Removed DOM manipulation hiding <div> containing the flag.
  4. Reloaded page in browser — flag was visible.

Challenge: Planets

Points: 50 Flag: flag{bcf58de4a0a17e8dfbdeaa8d1a1dbe9f}

Steps Taken:

  1. Inspecting the JavaScript source
    • Found that /api.php was being called with a raw SQL query in the body:
      1
      
       query=SELECT * FROM planets
      
    • No sanitization or prepared statements were used, indicating SQL Injection was possible.
  2. Replaying the request
    • Used an HTTP client (Burp Suite, Postman, or browser DevTools) to send the same POST request and confirm the backend returned JSON data.
  3. Enumerating database tables
    • Modified the query to:
       SELECT table_name FROM information_schema.tables
      
    • Received a list of tables, including a suspicious one: abandoned_planets.
  4. Dumping the target table
    • Queried:
      1
      
       SELECT * FROM abandoned_planets
      
    • The description field of the first record contained the flag.

Challenge: Buster

Points: 50 Flag: flag{deca3b9a1e42d25c508f78a5674f4f5e}

Steps Taken:

  1. Navigated to https://buster.ctf.zone/ and tested partial paths like /f, /fl, /fla.
  2. Observed HTTP 200 responses for correct prefixes → identified prefix oracle behavior.
  3. Noted flag format flag{[0-9a-f]{32}} and created character set 0123456789abcdef}.
  4. Discovered { and } must be URL-encoded (%7B and %7D).
  5. Wrote initial Python script to brute force one character at a time using GET requests:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
     import requests
     import urllib.parse
    
     BASE = "https://buster.ctf.zone/"
     charset = "0123456789abcdef}"
     flag = "flag{"
     attempts = 0
    
     print("[*] Starting brute force...")
    
     while not flag.endswith("}"):
         for ch in charset:
             test = flag + ch
             attempts += 1
             encoded_path = urllib.parse.quote(test)
             r = requests.get(BASE + encoded_path, allow_redirects=False)
             if r.status_code == 200:
                 flag = test
                 print(f"[+] Found next char: {ch} -> {flag} (Attempts: {attempts})")
                 break
    
     print(f"[!] Final flag: {flag} (Total Attempts: {attempts})")
    
  6. Implemented logging to print each found character and total attempts.
  7. Encountered server instability (TLS resets and timeouts) → enhanced script with retries, backoff, and progress file to resume from last successful character:
    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
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    
     import os
     import time
     import random
     import urllib.parse
     import requests
     from requests.adapters import HTTPAdapter
     from urllib3.util.retry import Retry
    
     BASE = "https://buster.ctf.zone/"
     CHARSET = "0123456789abcdef}"
     PROG_FILE = "buster_progress.txt"
    
     flag = "flag{"
     if os.path.exists(PROG_FILE):
         with open(PROG_FILE, "r") as f:
             last = f.read().strip()
             if last.startswith("flag{"):
                 flag = last
                 print(f"[*] Resuming from progress file: {flag}")
    
     retry = Retry(
         total=5,
         connect=5,
         read=5,
         backoff_factor=0.5,
         status_forcelist=[429, 500, 502, 503, 504],
         allowed_methods=["GET"],
         raise_on_status=False,
     )
     sess = requests.Session()
     sess.mount("https://", HTTPAdapter(max_retries=retry))
     sess.headers.update({"User-Agent": "ctf-buster/1.0"})
    
     attempts = 0
     print("[*] Starting brute force...")
    
     while not flag.endswith("}"):
         for ch in CHARSET:
             test = flag + ch
             enc = urllib.parse.quote(test, safe="/")
             attempts += 1
             try:
                 r = sess.get(BASE + enc, allow_redirects=False, timeout=5)
                 code = r.status_code
             except requests.RequestException as e:
                 print(f"[!] Transient error on '{test}': {e}. Backing off...")
                 time.sleep(0.5 + random.random() * 0.75)
                 continue
    
             if code == 200:
                 flag = test
                 print(f"[+] Found next char: {ch} -> {flag} (Attempts: {attempts})")
                 with open(PROG_FILE, "w") as f:
                     f.write(flag)
                 break
    
     print(f"[!] Final flag: {flag} (Total Attempts: {attempts})")
     print(f"[*] Progress saved to {PROG_FILE}")
    
  8. Ran the script until } was found, totaling 317 attempts.

Challenge: Shoe Shop 1.0

Points: 50 Flag: flag{13a5a584df37db8f07012c2277a50a2f}

Steps Taken:

  1. Created account and viewed cart.
  2. Cart API call contained id parameter matching user ID.
  3. Changed id to 1 — returned another user’s cart with flag.

📚 Lessons Learned

  • Detailed enumeration pays off in Network and Web categories.
  • Always document exact tool commands for reproducibility.
  • Forensics sometimes requires manual visual work when automation fails.
  • Knowing when to move on from an unsolved challenge is key for time management.

Note: AI-assisted editing was used only for grammar, formatting, and consistency. All opinions are my own based on official OffSec documentation.

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