πŸ—ΊοΈMapna CTF 2024

My writeups, sorted by category and roughly by easiest to hardest overall.

Crypto

What Next? (325 solves)

Description:

In this task, we explore the realm of cryptographically secure random generators, where predicting the next output is deemed impossible. Are you ready to test your luck and skill?

Solution:

TMP is ignored, enc is simply flag XOR'd with KEY, which is reversible since we're given KEY and enc.

from Crypto.Util.number import long_to_bytes

KEY = 23226475334448992634882677537728533150528705952262010830460862502359965393545
enc = 2290064970177041546889165766737348623235283630135906565145883208626788551598431732

decrypted = enc ^ KEY
flag = long_to_bytes(decrypted)

print("Decrypted flag:", flag)

Decrypted flag: b'MAPNA{R_U_MT19937_PRNG_Predictor?}'

What Next II? (69 solves)

Description:

Again, in this task, we explore the realm of cryptographically secure random generators, where predicting the next output is deemed impossible. Are you ready to test your luck and skill this time?

Solution:

This is a simple Mersenne Twister application to predict python random output, read here if unfamiliar: https://github.com/tna0y/Python-random-module-cracker

This requires 624 32-bit values which we are almost exactly given. We just need to be sure to give the last 624 values so we skip the first two entries in TMP.

from Crypto.Util.number import long_to_bytes
from randcrack import RandCrack

TMP = [0, 22330693840234311255135949029444484409546667648719176405826663892267656641027, 127168478027482847709328807841325386271927515479937061237117195618823278578116, 182258311374053859620888699680212168010665323374548870180038645090147843867373, 1120044041165490856498692287111236626472260308631093314161690677868431277653536, 1983473421395194676263973602935227753154638099492341714205203280778040675593450, 1574768551732085861078069762534699936995654652684634077104498873387111232412816, 4988773041677976257517254491234335651753610239922582254283447205154548743632904, 869738033317159039287197189670964123964466628318970710545560734535418094431872, 716771557072892076589368879721160406613516964478389692662921907034616035095047, 2841054733362182186252458286741823726277405165099408732758691872324732479956600, 6200268989316199565071790593244237980113705529543497656127585449937778556282311, 10670728743047162087774896911955052588177734200772863764402582886370432879158720, 7713906922622752752151916696524419287963819641354815269293605765422900017233866, 13689077681405838115291939958594572280593102467042881661528817316126253635857444, 23677404931618939684375357302211056316481456538100460743428412550112769975941300, 22334702277647520331031971258896634990832479997228972554803329027443498276011264, 24695994670269108821474844143270568317378271123560130717104045624895774803117988, 10726839246587772223823222881528936091917884797218227418638385365176143122217812, 1312747277711228023681888222399668996816715931126782050057534166588569071642948, 14829434912751138825019062212374862054511849430113519894438429231649766515851600, 4917180643387964007287001238070594020985844865025196727991425387470641537875518, 50772176246766546694026388399540445347088279634906123947563600159509306535585300, 20680598744337311676861190641592800456437920078216405214477640693225317242487078, 57560623230262776939750106414721715686651269149245752162663251361023294801081600, 63941301709699592129851769466238327968731332723117779339939586823464299930335000, 30248094445348087425063737332624900285689080519537666953907462011122884602991780, 9774708715683840095021685805936771586028623975773332766526807054152590972465402, 61228751294246951869891671407294469506401133460669313068369993608651062307301536, 41981261972157910420555352577742115252749734931422260886610665615142932761250238, 92332289648534120255700799585162857690611895814212622902006472593032842219422300, 102090694836612045964656351247645673041342905792690679450732518780700786595757872, 8465306744686231379969736050689382339949995071265316552433666241539252681451520, 114072153081233359084524715014825650254537286682603109151986752844288607088786066, 39946361462751138749261511325777846481011288953117931061771127396007551287911208, 51243479474799144289518571031495536096625532453885999576052634625243425716758700, 132356504405092579871543186323238530972479261975470487510352508943760068475015440, 109077835346013498228568867183016137777644328620298812835459712256002833220195417, 52635267919343130972014005273289555808336337947193348140410148289978267235415648, 9568343438735227407132147420705807168258684366618511035784505242511446472528193, 136103745592722122037143341370556407561964415802887285393102934361453911394982400, 15501324115571167305412632833471884183641743683875758176471163573103721210677697, 124579054262159655532164017523017564697199759561416452868759873217906475930663652, 69331433672201876294056448428159828327113921951663941374636039203754564050923557, 134825790087045765574290263555594553874136924161813224135475519279020442040026864, 127098236196925756090074171499128508507461799729629969599917408442298996799214250, 120716315173788627251671396349879537684221828425501013413665864262612928100844788, 246230945837378885729579613348413794121875158206606559652651668292953179058653508, 182436930868427241575608788617950343128628563937798409868187047670441481734494464, 2216307326510769061988701188806623458793041637834505792592287312459658319545700, 217778196427604121810125555838576095983026719310491477185297193068203986197977500, 200042153662024093707446037685450040433674498805614787040971237961725493946807124, 55096521527758008435839474651130444687406648424301616531387151625485823586357376, 315911207494925949742212443025101639383551363855632617410391325922132118052280432, 160608721274889447938606989650810386105243008009388938737103600719751998405695052, 80485718020426913778898398898436382386718914865993732581279132006386834763843750, 175256027423949464821148437330609889703365513530429385704635213979205690543187968, 312494592697141143680238564093947039458907138790072672218576868913190841311490441, 12551558878313236197845748627693664902436846005140074555532691630477757920400492, 368163678666609325358026149200535116090648801749210267074911311082497122727619418, 132244486142872991925346591101049195464960273281071718729683433268064480383763200, 187524820546739515326467479985404725103464284941528452333038247179114024353648176, 283320427018968981710753682470612392210145925235229015984823155988278867852342424, 273076274412276025537791810337835157311632197268182698230310819989050497776963263, 327014096802403962955714851262399814244813548393488285833127238998882721132883968, 206832690482752439833856322955815020186765387390104398292271480795930880106073325, 104167288428075991079921385804154376915444422785935287020330329091692992364020356, 468442878028756757484855000070722747267796721762231179211069666438706434848755245, 13006681553773847728990900149289800641720551387610802780788594468812438984199760, 199716185379958028413200192962692404940513822154864483463050473557869065589649168, 412558417168152436059170177108518481504104909389966119467224740980715361039084900, 379013360598848524426838307544021120793535763669172279637583374247930017257612752, 79510803625960975136293110699095743477640774841480691165531320726532279504009152, 119246467719878286004186703543298639812649580965124121805161153548472942538790653, 44235048729597559877492812430806736314711896199059487848598597142896457753232432, 453319033816285234767354843915966019736243075972507643199351036007057824008570000, 300975897791737470999557383409844137620736489995632513055593286593028252152372832, 488688724028459389993054497130088474659149461722402520817247390457263798063265080, 98311703485802819685121101139900586756957739352203591545958914778011243453808576, 503894794312461918204750180188338003935699664049776370432270755067603639622480931]

rc = RandCrack()

# Use values from TMP to reconstruct the state of the PRNG
for i, val in enumerate(TMP):
    if i > 1:  # Skip the first two values to provide exactly 624 32-byte chunks (79 * 8)
        extracted_val = val // (i ** 2)
        # Split the 256-bit number into eight 32-bit chunks
        for shift in range(0, 256, 32):
            chunk = (extracted_val >> shift) & 0xFFFFFFFF
            rc.submit(chunk)

# Predict the outputs of the PRNG to determine the KEY
predicted_key_parts = [rc.predict_getrandbits(256 >> _) ** 2 for _ in range(8)]
KEY = sum(predicted_key_parts)

# Given value for enc
enc = 1954128229670403595826293823451515985816812578139791173172421160740653397416251058891670696398940725266238000104900728729829302299509397650740333416176077

# Decrypting the message
decrypted = enc ^ KEY
flag = long_to_bytes(decrypted)

print("Decrypted flag:", flag)

Decrypted flag: b'MAPNA{4Re_y0U_MT19937_PRNG_pr3d!cT0r_R3ven9E_4057950503c1e3992}'

Forensics

PLC I πŸ€– (383 Solves)

Description:

The MAPNA CERT team has identified an intrusion into the plant's PLCs, discovering a covert message transferred to the PLC. Can you uncover this secret message?

Solution:

└─$ strings plc.pcap              
4-"@
(-#@
>-$@
A-%@
/-&@
I-'@
/-(@
I-)@
/-*@
I-+@
/-,@
I--@
3:Ld_4lW4
6ES7 151-8AB01-0AB0 
/-.@
E-/@
/-0@
I-1@
/-2@
I-3@
IM151-8 PN/DP CPU
/-4@
5:3__PaAD
E-5@
/-6@
1:MAPNA{y
E-7@
/-8@
4:yS__CaR
O-9@
 #      !
/-:@
E-;@
/-<@
6:d1n9!!}
E-=@
Y3td
/->@
2:0U_sHOu
(-?@

Flag is visible, simply assemble 1:MAPNA{y through 6:d1n9!!}.

MAPNA{y0U_sHOuLd_4lW4yS__CaR3__PaADd1n9!!}

Tampered (65 solves)

Description:

Our MAPNA flags repository was compromised, with attackers introducing one invalid flag. Can you identify the counterfeit flag?

Note: Forgot the flag format in the rules pages, just find the tampered one.

You are not allowed to brute-force the flag in scoreboard, this will result in your team being blocked.

Solution:

We get a text file with many flags in it. We need to see which was tampered so my first instinct is to scroll through and see if there are any with a different length. 30 seconds later holding ctrl+d in vim, I find it.

MAPNA{Tx,D51otN\eUf7qQ7>ToSYQ;5P6jTIHH#6TL+uv}

Web

Flag Holding (317 solves)

Description:

Hopefully you know how web works...

http://18.184.219.56:8080/

Solution:

Navigating to the site, you see: You are not coming from "http://flagland.internal/". A quick google search to find you just need to intercept the request in burp and add: Referer: http://flagland.internal/ Next, it says Unspecified "secret". There are multiple ways to specify a variable, easiest is in the url adding ?=

Next, it says Incorrect secret.

Press ctrl+u to see the page source which has a hint that the secret is the protocol the server and browser agree on, which ends up being http. Next, it says Sorry we don't have "GET" here but we might have other things like "FLAG".

This means instead of a GET request, we just make a FLAG request. Finally:

Reverse

Compile Me! πŸ”¨ (141 solves)

Description:

Compile the given code and execute the resulting binary, passing the source code file as an argument, to obtain the flag.

Welcome,to,MAPNA,CTF,Year_2k24;main(){for(++CTF;to=-~getchar();Welcome+=11==to,Year_2k24++)CTF=to>0xe^012>to&&'`'^to^65?!to:!CTF?++MAPNA:CTF;printf("MAPNA{%4d__%d__%d_!}\n",(to+20)^(Welcome+24)+1390,MAPNA+=(!CTF&&Year_2k24)+10,Year_2k24+31337);}

Solution:

Pretty straight-forward, seems like we just gcc file.c -o file; ./file < file.c right?

MAPNA{1427__11__31583_!}

But wait, it's not working. Let's not talk about how much time I wasted on this, here's what worked.

After running, stdout shows MAPNA{1426__11__31582_!}

Heaverse (42 solves)

Description:

Heaverse, a paradoxical binary that defies logic: reverse it without reversing it. Can you navigate its enigmatic depths?

Flag format: MAPNA{CAPITAL_WORDS_THAT_YOU_FIND}

Solution:

Run Heaverse with gdb (I have pwndbg set up), ctrl-c when a beep is played, see this:

We see morse code in RBX, when continuing and ctrl+c, we see the morse passed in is slowly iterated. Therefore we just need to print out the memory at that location to see the full string. Printing at an address slightly above we get:

Copy paste into cyberchef to decode the morse, and you get JUS7LIST3NN0TREV3RSE but it doesn't work directly, follow the instructions in the description to get:

MAPNA{JUS7_LIST3N_N0T_REV3RSE}

Solution:

The given binary reads a flag and allows the user to either gather information or confirm the flag. Decompiling it doesn't help much and with simple trial and error, it can be seen that only a single character input is allowed. Manually trying each likely character and copy-pasting/cleaning up the result gets the following:

char_values = {
    "0": 21279, "1": 2704, "2": 0, "3": 4373, "4": 5805, "5": 484, "6": 0, "7": 1296, "8": 400, "9": 0,
    "!": 8296, "@": 0, "#": 0, "$": 0, "%": 0, "^": 0, "&": 0, "*": 0, "(": 0, ")": 0,
    "-": 0, "_": 43159, "=": 0, "{": 25, "}": 8649, "\\": 0, "|": 0, "/": 0, "?": 16745, ".": 0,
    ",": 0, "<": 0, ">": 0, "a": 2098, "b": 6241, "c": 2930, "d": 10001, "e": 5573, "f": 4096,
    "g": 1849, "h": 6970, "i": 0, "j": 5329, "k": 4437, "l": 0, "m": 0, "n": 7145, "o": 8586,
    "p": 0, "q": 0, "r": 8704, "s": 5929, "t": 4689, "u": 8077, "v": 2116, "w": 0, "x": 4356,
    "y": 12962, "z": 4900, "A": 458, "B": 0, "C": 1024, "D": 0, "E": 0, "F": 1156, "G": 7225,
    "H": 0, "I": 0, "J": 0, "K": 0, "L": 4705, "M": 6301, "N": 850, "O": 0, "P": 6564, "Q": 2500,
    "R": 0, "S": 1444, "T": 3305, "U": 0, "V": 0, "W": 6724, "X": 0, "Y": 0, "Z": 0
}

We notice that { is 25 and } is 8649, which happen to be 5 and 93 squared. Since we know the flag likely starts with MAPNA{, we can assume the numbers refer to the 0-indexed position. It's not that simple though since most numbers aren't a perfect square. Here's where things get a bit messy. We know we didn't miss any characters since these sum to what the sum of squares is.

Next, we can see what the flag looks like if we assume perfect squares go in their correlated position.

import math

def is_perfect_square(n):
    return n == int(math.sqrt(n)) ** 2

char_values = {
    "0": 21279, "1": 2704, "2": 0, "3": 4373, "4": 5805, "5": 484, "6": 0, "7": 1296, "8": 400, "9": 0,
    "!": 8296, "@": 0, "#": 0, "$": 0, "%": 0, "^": 0, "&": 0, "*": 0, "(": 0, ")": 0,
    "-": 0, "_": 43159, "=": 0, "{": 25, "}": 8649, "\\": 0, "|": 0, "/": 0, "?": 16745, ".": 0,
    ",": 0, "<": 0, ">": 0, "a": 2098, "b": 6241, "c": 2930, "d": 10001, "e": 5573, "f": 4096,
    "g": 1849, "h": 6970, "i": 0, "j": 5329, "k": 4437, "l": 0, "m": 0, "n": 7145, "o": 8586,
    "p": 0, "q": 0, "r": 8704, "s": 5929, "t": 4689, "u": 8077, "v": 2116, "w": 0, "x": 4356,
    "y": 12962, "z": 4900, "A": 458, "B": 0, "C": 1024, "D": 0, "E": 0, "F": 1156, "G": 7225,
    "H": 0, "I": 0, "J": 0, "K": 0, "L": 4705, "M": 6301, "N": 850, "O": 0, "P": 6564, "Q": 2500,
    "R": 0, "S": 1444, "T": 3305, "U": 0, "V": 0, "W": 6724, "X": 0, "Y": 0, "Z": 0
}

flag_length = 94  # As positions are 0-indexed

# Initialize the flag
flag = ['*'] * flag_length

# Lists to keep track of characters with single and multiple occurrences
single_occurrences = {}
multiple_occurrences = {}

# Assign characters to positions or list them as candidates
for char, value in char_values.items():
    if is_perfect_square(value):
        position = int(math.sqrt(value))
        flag[position] = char
        single_occurrences[char] = position
    else:
        # If the value is not a perfect square, list the character for further analysis
        if value != 0:
            multiple_occurrences[char] = value

# Display the partially filled flag and characters with multiple occurrences
partial_flag = ''.join(flag)
print("Single Occurrences:", single_occurrences)
print("Multiple Occurrences or Uncertain Positions:", multiple_occurrences)
print("Partially Filled Flag:", partial_flag)

Running this script to fill out positions that are a perfect square, keeping in mind some of these may be incorrect and perfect squares can be made up of sums of squares, we get the following:

Single Occurrences: {'1': 52, '2': 0, '5': 22, '6': 0, '7': 36, '8': 20, '9': 0, '@': 0, '#': 0, '$': 0, '%': 0, '^': 0, '&': 0, '*': 0, '(': 0, ')': 0, '-': 0, '=': 0, '{': 5, '}': 93, '\\': 0, '|': 0, '/': 0, '.': 0, ',': 0, '<': 0, '>': 0, 'b': 79, 'f': 64, 'g': 43, 'i': 0, 'j': 73, 'l': 0, 'm': 0, 'p': 0, 'q': 0, 's': 77, 'v': 46, 'w': 0, 'x': 66, 'z': 70, 'B': 0, 'C': 32, 'D': 0, 'E': 0, 'F': 34, 'G': 85, 'H': 0, 'I': 0, 'J': 0, 'K': 0, 'O': 0, 'Q': 50, 'R': 0, 'S': 38, 'U': 0, 'V': 0, 'W': 82, 'X': 0, 'Y': 0, 'Z': 0}
Multiple Occurrences or Uncertain Positions: {'0': 21279, '3': 4373, '4': 5805, '!': 8296, '_': 43159, '?': 16745, 'a': 2098, 'c': 2930, 'd': 10001, 'e': 5573, 'h': 6970, 'k': 4437, 'n': 7145, 'o': 8586, 'r': 8704, 't': 4689, 'u': 8077, 'y': 12962, 'A': 458, 'L': 4705, 'M': 6301, 'N': 850, 'P': 6564, 'T': 3305}
Partially Filled Flag: Z****{**************8*5*********C*F*7*S****g**v***Q*1***********f*x***z**j***s*b**W**G*******}

Ignore the Z at the start since the first position corresponds to 0.

We can manually add MAPNA at the beginning and subtract the squares from the candidates (e.g., A - 1, P -4, N-9, A-16). Next we see what it looks like when we try to fill by calculating sums of squares.

import math
from itertools import combinations

def is_perfect_square(n):
    return n == int(math.sqrt(n)) ** 2

flag_length = 94  # As positions are 0-indexed

# The partially filled flag
partial_flag = "MAPNA{**************8A5*********C*F*7*S****g**v***Q*1***********f*x***z**j***s*b**W**G*******}"

# Updated character values after manual cleanup
updated_char_values = {
    '3': 4373, '4': 5805, '!': 8296, '_': 43159, '?': 16745, 'a': 2098, 'c': 2930, 'd': 10001,
    'e': 5573, 'h': 6970, 'k': 4437, 'n': 7145, 'o': 8586, 'r': 8704, 't': 4689, 'u': 8077,
    'y': 12962, 'L': 4705, 'M': 6301, 'N': 841, 'P': 6560, 'T': 3305
}

# Convert the partial flag to a list for easier manipulation
flag_list = list(partial_flag)

# Calculate the squares of all possible positions
possible_positions = list(range(flag_length))

# Exclude positions that are already filled (not a '*' or '?')
excluded_positions = [i for i, char in enumerate(flag_list) if char not in ['*', '?']]

# Update possible positions to exclude the filled positions
updated_possible_positions = [pos for pos in possible_positions if pos not in excluded_positions]

def find_combinations_for_value(value, possible_positions, max_combination_length):
    """ Find combinations of positions that match the given value up to a maximum number of squares. """
    for r in range(2, max_combination_length + 1):  # Start from 2 as we're looking for sums of squares
        for combo in combinations(possible_positions, r):
            if sum([p ** 2 for p in combo]) == value:
                return combo
    return ()

# For now only allow sum of two squares
max_combination_length = 2

# Finding combinations for remaining characters with updated values
for char, value in updated_char_values.items():
    if value > 0:  # Only consider characters with positive adjusted values
        combos = find_combinations_for_value(value, updated_possible_positions, max_combination_length)
        if combos:
            # Fill in the flag positions based on the found combinations
            for pos in combos:
                if flag_list[pos] in ['*', '?']:
                    flag_list[pos] = char

# Convert the flag list back to a string
updated_flag_str = ''.join(flag_list)
print(updated_flag_str)
MAPNA{***h*c*T!*n***8A53**MaP***CtF*7aSk***g*over*Qu1ck*T*e*t*3*fdx*L*zy*juMds*broWh*G***y!??}

From here we can already see a lot of the flag, CtF, 7aSk, and a scrambled "The quick fox jumped over the lazy dog". Everything so far seems mostly right except d and h which we can manually reverse.

After fixing the above phrase and adding _ where obvious, there wasn't much left. I created this utility that allows me to determine all sums of squares resulting in a number, while capping the minimum square allowed.

from math import isqrt

def sum_distinct_squares_solutions(n, min_square=1):
    min_square_root = isqrt(min_square)

    # Initialize the list of solutions for each sum up to n
    W = [[] for _ in range(n + 1)]
    W[0] = [[]]

    for i in range(isqrt(n), min_square_root - 1, -1):
        i2 = i * i
        for j in range(i2, n + 1):
            for solution in W[j - i2]:
                # Check if i is not in the solution and all numbers are above or equal to min_square_root
                if i not in solution and all(num >= min_square_root for num in solution):
                    W[j].append(solution + [i])

    return W[n]

n = int(input("Enter a number to find solutions of distinct squares: "))
min_square = int(input("Enter the minimum square value to be used: "))
solutions = sum_distinct_squares_solutions(n, min_square)

print(f"Distinct square solutions for {n}, starting from square {min_square}:")
for solution in solutions:
    print(solution)

Filling in the remainders, we get the final flag!

MAPNA{___L0c4T!0n___8A53d_MaPN4_CtF_7aSk_d0g_over_Qu1ck_The_th3_f0x_L4zy_juMPs_broWn_G00dy!??}

Last updated