DEF CON CTF Qualifier 2022 WriteUps

發表於
分類於 CTF

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.

  Scoreboard Screenshot

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 mm bits -> nn bits CRC,

CRC(x)=Ax+C\operatorname{CRC}(x) = Ax + C

where xx is an mm-dimensional vector, AA is an n×mn \times m matrix, and CC is an nn-dimensional vector. This part can treat the CRC function as a black box and try to recover AA and CC, avoiding the hassle of dealing with padding details.

I wrote a sage script for this: crcmat.sage

So, consider the concatenated string as m+dm+d, where mm is the padded prefix, and dd is the part to be concatenated. When the target CRC is tt, we have A(m+d)+C=tA(m+d)+C=t, so solving Ad=tCAmAd=t-C-Am gives dd. However, the dd found in this way is generally not alphanumeric, so additional handling is needed.

It is known that when m>nm>n under normal circumstances, the rank of the right kernel of AA is very high. Moreover, adding any vector from the right kernel to dd still keeps the CRC the same, so a method is needed to combine dd 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 nn characters results in a constant kk vector (0011000000110000....) and an additional 3nn free vectors. Let's call these free vectors basis, so we have d+span(kernel)=k+span(basis)d + span(kernel) = k + span(basis). 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.