DEF CON CTF Qualifier 2022 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This time, we took second place in the team Balsn.217@TSJ.tw. However, as a web/crypto player, I really felt like I couldn't do much in this competition because the challenges were too rev/pwn heavy. Here, I will briefly record the solutions to some of the challenges I participated in, as many of them were completed through team collaboration.
sameold
This challenge requires finding a string x
that starts with the team's name in punycode, followed by any alphanumeric characters, such that crc32(x) == crc32("the")
.
The simplest solution is to brute force it, which is also the expected solution. Interestingly, there was a team named the
in this competition, so they could easily solve this challenge.
However, I later came up with a mathematical solution using the properties of CRC, which requires some linear algebra. For an bits -> bits CRC,
where is an -dimensional vector, is an matrix, and is an -dimensional vector. This part can treat the CRC function as a black box and try to recover and , avoiding the hassle of dealing with padding details.
I wrote a sage script for this: crcmat.sage
So, consider the concatenated string as , where is the padded prefix, and is the part to be concatenated. When the target CRC is , we have , so solving gives . However, the found in this way is generally not alphanumeric, so additional handling is needed.
It is known that when under normal circumstances, the rank of the right kernel of is very high. Moreover, adding any vector from the right kernel to still keeps the CRC the same, so a method is needed to combine with those vectors to form an alphanumeric vector.
After consulting Utaha, I learned that the ASCII characters 0~7
have ASCII codes in the format 00110???
, so each character can be seen as a combination of 00110000
and 3 vectors. Appending characters results in a constant vector (0011000000110000....
) and an additional 3 free vectors. Let's call these free vectors basis
, so we have . Therefore, by solving this linear system with kernel and basis on the same side, and taking sol[:dim(kernel)] * kernel + d
, we get an alphanumeric vector that keeps the CRC the same.
Solver: DEF CON Quals 2022- sameold
Hash it
Reversing its binary reveals that it takes input bytes two by two, hashes them, and uses the first byte of the hash to form shellcode, which is then executed. The hash part cycles through md5, sha1, sha256, and sha512, so we can write a script to brute force the shellcode back to the original input bytes according to its logic.
Script: DEF CON Quals 2022 - Hash It
discoteq
This challenge involves a chat application written in Flutter Web/Desktop, where the admin (bot) uses the desktop version to receive your messages.
The communication part uses WebSocket, and the sending and receiving parts roughly follow this format:
{"type":"widget","widget":"/widget/charmessage","author":{"user":"aaaa#8763","platform":"web"},"recipients":["admin#13371337"],"data":{"message":"abc"}}
The widget
part is processed by getChatWidget
on the client side:
getChatWidget(url) {
var $async$goto = 0,
$async$completer = A._makeAsyncAwaitCompleter(type$.RemoteChatWidget),
$async$returnValue, t1, decoder, result, t2, widget, existing;
var $async$getChatWidget = A._wrapJsFunctionForAsync(function($async$errorCode, $async$result) {
if ($async$errorCode === 1)
return A._asyncRethrow($async$result, $async$completer);
while (true)
switch ($async$goto) {
case 0:
// Function start
existing = $.chatWidgetCache.$index(0, url);
if ($.config.$index(0, "auto") !== true && existing != null) {
$async$returnValue = existing;
// goto return
$async$goto = 1;
break;
}
$async$goto = 3;
return A._asyncAwait(A.makeRequest("GET", url, null, true, null), $async$getChatWidget);
case 3:
// returning from await.
t1 = $async$result.bodyBytes;
t1 = A.NativeByteData_NativeByteData$view(t1.buffer, t1.byteOffset, t1.byteLength);
decoder = new A._BlobDecoder(t1);
decoder.expectSignature$1(B.List_254_82_70_87);
result = new A.RemoteWidgetLibrary(decoder._readImportList$0(), decoder._readDeclarationList$0());
if (decoder._binary$_cursor < t1.byteLength)
A.throwExpression(B.FormatException_gkJ);
t1 = A.LinkedHashMap_LinkedHashMap$_empty(type$.LibraryName, type$.WidgetLibrary);
t2 = type$.FullyQualifiedWidgetName;
t2 = new A.Runtime(t1, A.LinkedHashMap_LinkedHashMap$_empty(t2, type$.nullable__ResolvedConstructor), A.LinkedHashMap_LinkedHashMap$_empty(t2, type$._CurriedWidget), A.List_List$filled(0, null, false, type$.nullable_void_Function));
widget = new A.RemoteChatWidget(result, t2);
t1.$indexSet(0, B.LibraryName_List_core_widgets, new A.LocalWidgetLibrary(A._coreWidgetsDefinitions()));
t2._clearCache$0();
t1.$indexSet(0, B.LibraryName_List_core_material, new A.LocalWidgetLibrary(A._materialWidgetsDefinitions()));
t2._clearCache$0();
t1.$indexSet(0, B.LibraryName_List_local, new A.LocalWidgetLibrary(A.LinkedHashMap_LinkedHashMap$_literal(["URLImage", A.main_URLImage_FromSource$closure(), "ApiMapper", A.main_ApiMapper_FromSource$closure()], type$.String, type$.Widget_Function_BuildContext_DataSource)));
t2._clearCache$0();
t1.$indexSet(0, B.LibraryName_List_remote, result);
t2._clearCache$0();
$.chatWidgetCache.$indexSet(0, url, widget);
$async$returnValue = widget;
// goto return
$async$goto = 1;
break;
case 1:
// return
return A._asyncReturn($async$returnValue, $async$completer);
}
});
return A._asyncStartSync($async$getChatWidget, $async$completer);
},
The url
part is directly concatenated, so if widget
is @attacker.host/malicious_file
, it can control the payload entering RemoteWidgetLibrary
.
This feature is part of Flutter's Remote Flutter Widgets functionality, which is a serialization format. By downloading and running that library, and then trying to parse and compare its original rfw files, we can see its capabilities.
In the end, we find that it has an ApiMapper
that allows you to get JSON and extract a key to set data, and an post_api
event that can POST data to other places. The URL can also be controlled using @
to send data to your own server.
So, we try to make the admin send the new_token
from /api/token
to our server, and then use the token to POST to /api/flag
.
Payload and other related scripts: DEF CON Quals 2022 - discoteq
admad
This challenge is a Python pyc reverse challenge, a flag checker type, but it provides a custom-compiled Python interpreter with many additional libraries removed.
The Python version of the challenge is specified as 702e0da000bf28aa20cb7f3893b575d977506495, so we can download and compile it. However, running chall.pyc
with the compiled version results in a segfault, so we have to use the provided Python interpreter.
splitline mentioned that the challenge crashes because its bytecode has been swapped, but the binary is stripped, making it difficult to reverse engineer the changes. The solution is to refer to kholia/dedrop and write a Python file containing (almost) all opcodes, then generate two pyc files and compare the opcodes to create a mapping.
You can see my xx.py and xx.sh for this part.
After obtaining the mapping, we can rewrite the entire pyc to run on a normal interpreter and use dis.dis
to disassemble it. Reading the bytecode reveals that it simply takes flag indices and uses &
operations with a mask to compare, which can be easily reversed to pass the check.
You can refer to rev.py for this part.
However, after reversing, the input appears to be garbled and not a flag. Later, a teammate figured out that it could be reversed using this method:
test = b'\x8e\x86\x8d\x95\xbb\xaa\xb9\xb2\xc8\xb3\xba\xc5\xaf\xc3\xc8\xc7\xc5\xb9\xcf\xe3\xe3\xe6\xd3\xd3\xd1\xe4\xe1\xcd\xe3\xf2\xed\xfa\xe0\xe9\xea\xddC0EH\xe7\xf3\x0f\xed\x06\x17\x02\xf5_Z^\x13`\x16\x15fhj)'
now=56
now2=72
for i in test:
print(chr((i-now)%128),end='')
print(chr((i-now2)%128))
now+=2
now2+=2
# then guess FLAG{helping_adam_customize_cpython_3.12_is_fun_702e0da000}
It turns out that the interpreter also modifies bytes
, so if the parameter is a list and list[0] == ord('F')
, it messes with the values, using a formula similar to the one above for output.
router-niii
This challenge is the third in the router series, mainly involving attacking a binary downloaded from /vm
, which processes update data and is essentially an interpreter with its own opcodes.
Initially, I noticed that the binary's call
instructions were obfuscated into strange things, so I tried using capstone and pwntools to patch them back to normal call
instructions, allowing it to run normally and decompile in IDA: vm_patch.py.
Later, orange successfully reversed it and generated a payload to read /flag3
. However, the VM also accepts a seed
parameter (./vm <seed>
) as srand(seed)
, and uses the output values to randomly execute opcodes, so we need to know how the server executes ./vm
to determine the seed.
To achieve this, we need to use OOB reads (/ping?id=???
) to dump the server binary and reverse it. However, since it dumps 4096 bytes at a time, I couldn't dump the ELF header to reconstruct a reverseable ELF, so I couldn't reverse it successfully. This is also the key to solving the second challenge, router-nii.
After some random attempts, I found that /ping?id=1000
had strange data, with most of it being null except for the first four bytes. I guessed that this might be the seed for ./vm <seed>
. Combining orange's payload with a script, I found that this was indeed the case, and I luckily obtained the flag for the third challenge.
You can refer to solve.py for the script.