All Articles

SECCON CTF 2023 QUALS Writeup

This page has been machine-translated from the original page.

I participated in the SECCON CTF 2023 qualifying round (held from 9/16 to 9/17) as part of the team 0nePadding.

Individually, I solved 2 Rev challenges, finishing 35th domestically and 87th overall.

image-20230917221935162

The top 10 domestic teams that qualify for the SECCON national finals are still a high wall to clear, but I intend to keep grinding.

Since reviewing the unsolved problems will take time, I’ll write up the ones I actually solved for now.

Table of Contents

jumpout(Rev)

Sequential execution

This was the Warmup Rev challenge of the contest.

In terms of difficulty of obtaining the flag it was relatively easy, but it was an extremely interesting problem that gave me the strong impression that the author is deeply familiar with binaries and debuggers. (I’d love to see the source code for this — how was it even made…?)

The binary itself appeared to be a simple program that just validates a flag received as input, so I analyzed it with Ghidra.

Following the usual approach of analyzing the binary from entry and identifying the main function, I could see that it was calling something using the text FLAG:, but the Listing window output was corrupted and the decompilation was not working correctly.

image-20230917223341237

However, looking at the function referenced in main at address 0x1011d0, while the decompilation was still broken, I could tell that the (probably) input characters on the stack were being passed to a check function. (The check function name was one I renamed myself.)

image-20230917224759189

The check function looked like this:

undefined8 check(char *param_1)
{
  long lVar1;
  size_t sVar2;
  ulong uVar3;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  sVar2 = strlen(param_1);
  if (sVar2 == 0x1d) {
    do {
      uVar3 = 0;
      do {
        FUN_00101360(param_1[uVar3],uVar3 & 0xffffffff);
        uVar3 = uVar3 + 1;
      } while (uVar3 != 0x1d);
    } while( true );
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 1;
}

From this code, we can see that the correct flag length is 0x1d.

We can also see that it calls a function called FUN_00101360 with a single input character and its index as arguments.

FUN_00101360 is a function like the following — it returns the XOR of four values: one input character, its index, 0x55, and (&DAT_00104010)[index].

uint FUN_00101360(uint param_1,uint param_2)
{
  long in_FS_OFFSET;
  
  if (*(long *)(in_FS_OFFSET + 0x28) != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail((long)(int)param_2,param_2,7);
  }
  return param_1 ^ param_2 ^ 0x55 ^ (uint)(byte)(&DAT_00104010)[(int)param_2];
}

However, as you can see from the decompilation of check we looked at earlier, there is no code in the decompiled output that handles the return value of FUN_00101360.

My hypothesis was that it probably compares the returned value against some hardcoded value to perform verification, but that function’s decompilation also appeared broken and was not in a state where the correct behavior could be read.

Since that wasn’t working, I read the assembly of the check function and found that it was loading data from DAT_00104030, which seemed likely to be used later.

There was some guesswork involved, but I created the following solver using that data as the comparison target and was able to obtain the flag.

d = [ 0xf6, 0xf5, 0x31, 0xc8, 0x81, 0x15, 0x14, 0x68, 0xf6, 0x35, 0xe5, 0x3e, 0x82, 0x09, 0xca, 0xf1, 0x8a, 0xa9, 0xdf, 0xdf, 0x33, 0x2a, 0x6d, 0x81, 0xf5, 0xa6, 0x85, 0xdf, 0x17 ]

e = [ 0xf0, 0xe4, 0x25, 0xdd, 0x9f, 0x0b, 0x3c, 0x50, 0xde, 0x04, 0xca, 0x3f, 0xaf, 0x30, 0xf3, 0xc7, 0xaa, 0xb2, 0xfd, 0xef, 0x17, 0x18, 0x57, 0xb4, 0xd0, 0x8f, 0xb8, 0xf4, 0x23, 0x00 ]

for i in range(0x1d):
    print(chr(0x55^ i ^ d[i] ^ e[i]), end="")

During the contest I reached the above solver smoothly, but looking back at it afterward, the binary was quite difficult to read and seemed to assume dynamic analysis.

That said, the broad strokes don’t change — once you’ve identified the check function, following the detailed behavior with a debugger will lead you to the same conclusion.

Sickle(Rev)

Pickle infected with COVID-19

Due to my limited skill, Rev challenges requiring careful manual reading like this one take me a long time, and I only barely managed to submit the flag right at the very end of the contest.

The following script was given as the challenge binary:

import pickle, io

payload = b'\x8c\x08builtins\x8c\x07getattr\x93\x942\x8c\x08builtins\x8c\x05input\x93\x8c\x06FLAG> \x85R\x8c\x06encode\x86R)R\x940g0\n\x8c\x08builtins\x8c\x04dict\x93\x8c\x03get\x86R\x8c\x08builtins\x8c\x07globals\x93)R\x8c\x01f\x86R\x8c\x04seek\x86R\x94g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__add__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__mul__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x06__eq__\x86R\x940g3\ng5\n\x8c\x08builtins\x8c\x03len\x93g1\n\x85RM@\x00\x86RM\x05\x01\x86R\x85R.0g0\ng1\n\x8c\x0b__getitem__\x86R\x940M\x00\x00\x940g2\ng3\ng0\ng6\ng7\n\x85R\x8c\x06__le__\x86RM\x7f\x00\x85RMJ\x01\x86R\x85R.0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM@\x00\x86RMU\x00\x86RM"\x01\x86R\x85R0g0\ng0\n]\x94\x8c\x06append\x86R\x940g8\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\nfrom_bytes\x86R\x940M\x00\x00p7\n0g9\ng11\ng6\n\x8c\x08builtins\x8c\x05slice\x93g4\ng7\nM\x08\x00\x86Rg4\ng3\ng7\nM\x01\x00\x86RM\x08\x00\x86R\x86R\x85R\x8c\x06little\x86R\x85R0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RMw\x00\x86RM\xc9\x01\x86R\x85R0g0\n]\x94\x8c\x06append\x86R\x940g0\ng12\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__xor__\x86R\x940I1244422970072434993\n\x940M\x00\x00p7\n0g13\n\x8c\x08builtins\x8c\x03pow\x93g15\ng10\ng7\n\x85Rg16\n\x86RI65537\nI18446744073709551557\n\x87R\x85R0g14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RM\x83\x00\x86RM\xa7\x02\x86R\x85R0g0\ng12\n\x8c\x06__eq__\x86R(I8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R.'
f = io.BytesIO(payload)
res = pickle.load(f)

if isinstance(res, bool) and res:
    print("Congratulations!!")
else:
    print("Nope")

Running this, I found that the hardcoded binary is executed during pickle.load(f) deserialization, and it performs flag string prompting and validation.

I had not used the pickle library before, but it is a module that allows serialization and deserialization of Python objects.

Furthermore, when deserializing with the pickle.load method, it actually runs internally as a VM, giving it the ability to execute arbitrary code.

In this challenge, loading the series of binary data defined as payload triggers the logic that accepts a flag input and validates it.

References:

To analyze it, the payload must first be disassembled.

Disassembly can be performed using pickletools.dis().

import pickletools

payload = b'\x8c\x08builtins\x8c\x07getattr\x93\x942\x8c\x08builtins\x8c\x05input\x93\x8c\x06FLAG> \x85R\x8c\x06encode\x86R)R\x940g0\n\x8c\x08builtins\x8c\x04dict\x93\x8c\x03get\x86R\x8c\x08builtins\x8c\x07globals\x93)R\x8c\x01f\x86R\x8c\x04seek\x86R\x94g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__add__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__mul__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x06__eq__\x86R\x940g3\ng5\n\x8c\x08builtins\x8c\x03len\x93g1\n\x85RM@\x00\x86RM\x05\x01\x86R\x85R.0g0\ng1\n\x8c\x0b__getitem__\x86R\x940M\x00\x00\x940g2\ng3\ng0\ng6\ng7\n\x85R\x8c\x06__le__\x86RM\x7f\x00\x85RMJ\x01\x86R\x85R.0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM@\x00\x86RMU\x00\x86RM"\x01\x86R\x85R0g0\ng0\n]\x94\x8c\x06append\x86R\x940g8\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\nfrom_bytes\x86R\x940M\x00\x00p7\n0g9\ng11\ng6\n\x8c\x08builtins\x8c\x05slice\x93g4\ng7\nM\x08\x00\x86Rg4\ng3\ng7\nM\x01\x00\x86RM\x08\x00\x86R\x86R\x85R\x8c\x06little\x86R\x85R0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RMw\x00\x86RM\xc9\x01\x86R\x85R0g0\n]\x94\x8c\x06append\x86R\x940g0\ng12\n\x8c\x0b__getitem__\x86R\x940g0\n\x8c\x08builtins\x8c\x03int\x93\x8c\x07__xor__\x86R\x940I1244422970072434993\n\x940M\x00\x00p7\n0g13\n\x8c\x08builtins\x8c\x03pow\x93g15\ng10\ng7\n\x85Rg16\n\x86RI65537\nI18446744073709551557\n\x87R\x85R0g14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01\x00\x86Rp7\nM\x08\x00\x86RM\x83\x00\x86RM\xa7\x02\x86R\x85R0g0\ng12\n\x8c\x06__eq__\x86R(I8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R.'

pickletools.dis(payload, annotate=True)

Reference: pickletools --- Tools for pickle developers — Python 3.11.5 documentation

However, because a STOP instruction is present partway through, pickletools can only read up to offset 0x105.

image-20230917233959543

Moreover, even if you specify the payload range with a slice, pickletools will fail to interpret the POP and GET instructions and throw an error, causing disassembly to fail.

I therefore searched for another tool that could disassemble this payload and found r2pickledec, a radare2 plugin.

Reference: doyensec/r2pickledec: Pickle decompiler plugin for Radare2

This tool had many rough edges and various issues arose both during and after installation, but by first building the latest radare2 from source and then installing the plugin, I was able to successfully disassemble the entire payload.

The disassembly output with my analysis comments added is as follows:

0x000      8c086275696c.  short_binunicode "builtins" ; 0x2
0x00a      8c0767657461.  short_binunicode "getattr"  ; 0xc
0x013      93             stack_global
0x014      94             memoize
0x015      32             dup

memo0: getattr()
// getattr()
// getattr()

0x016      8c086275696c.  short_binunicode "builtins" ; 0x18
0x020      8c05696e7075.  short_binunicode "input"    ; 0x22 ; 2'"'
0x027      93             stack_global
0x028      8c06464c4147.  short_binunicode "FLAG> "   ; 0x2a ; 2'*'
0x030      85             tuple1
0x031      52             reduce
// getattr()
// getattr()
// input("FLAG> ")

0x032      8c06656e636f.  short_binunicode "encode"   ; 0x34 ; 2'4'
0x03a      86             tuple2
0x03b      52             reduce
0x03c      29             empty_tuple
0x03d      52             reduce
0x03e      94             memoize
0x03f      30             pop

memo0: getattr()
memo1: Flag

0x040      67300a         get "0"                     ; 0x41 ; 2'A'
0x043      8c086275696c.  short_binunicode "builtins" ; 0x45 ; 2'E'
0x04d      8c0464696374   short_binunicode "dict"     ; 0x4f ; 2'O'
0x053      93             stack_global
0x054      8c03676574     short_binunicode "get"      ; 0x56 ; 2'V'
0x059      86             tuple2
0x05a      52             reduce
0x05b      8c086275696c.  short_binunicode "builtins" ; 0x5d ; 2']'
0x065      8c07676c6f62.  short_binunicode "globals"  ; 0x67 ; 2'g'
0x06e      93             stack_global
0x06f      29             empty_tuple
0x070      52             reduce
0x071      8c0166         short_binunicode "f"        ; 0x73 ; 2's'
0x074      86             tuple2
0x075      52             reduce


0x076      8c047365656b   short_binunicode "seek"     ; 0x78 ; 2'x'
0x07c      86             tuple2
0x07d      52             reduce
// dict.get(builtins.globals(), f).seek()

0x07e      94             memoize

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek()

0x07f      67300a         get "0"                     ; 0x80
0x082      8c086275696c.  short_binunicode "builtins" ; 0x84
0x08c      8c03696e74     short_binunicode "int"      ; 0x8e
0x091      93             stack_global

// getattr()
// builtins.int()
// __add__

0x092      8c075f5f6164.  short_binunicode "__add__"  ; 0x94
0x09b      86             tuple2
// getattr()
// (builtins.int(), __add__)
0x09c      52             reduce
0x09d      94             memoize
0x09e      30             pop
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek()
memo3: add()

0x09f      67300a         get "0"                     ; 0xa0
0x0a2      8c086275696c.  short_binunicode "builtins" ; 0xa4
0x0ac      8c03696e74     short_binunicode "int"      ; 0xae
0x0b1      93             stack_global
0x0b2      8c075f5f6d75.  short_binunicode "__mul__"  ; 0xb4
0x0bb      86             tuple2
0x0bc      52             reduce
0x0bd      94             memoize
0x0be      30             pop
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek()
memo3: add()
memo4: mul()

0x0bf      67300a         get "0"                     ; 0xc0
0x0c2      8c086275696c.  short_binunicode "builtins" ; 0xc4
0x0cc      8c03696e74     short_binunicode "int"      ; 0xce
0x0d1      93             stack_global
0x0d2      8c065f5f6571.  short_binunicode "__eq__"   ; 0xd4
0x0da      86             tuple2
0x0db      52             reduce
0x0dc      94             memoize
0x0dd      30             pop
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()

0x0de      67330a         get "3"                     ; 0xdf
0x0e1      67350a         get "5"                     ; 0xe2
0x0e4      8c086275696c.  short_binunicode "builtins" ; 0xe6
0x0ee      8c036c656e     short_binunicode "len"      ; 0xf0
0x0f3      93             stack_global
0x0f4      67310a         get "1"                     ; 0xf5
0x0f7      85             tuple1
0x0f8      52             reduce
// add()
// eq()
// builtins.len(flag)

0x0f9      4d4000         binint2 0x40                ; '@'
0x0fc      86             tuple2

// add()
// eq()
// (builtins.len(flag), 0x40)

0x0fd      52             reduce
0x0fe      4d0501         binint2 0x105
0x101      86             tuple2
// add()
// (bool, 0x015)

0x102      52             reduce
// add(bool, 0x105)

0x103      85             tuple1
// jmp
// 0x106

0x104      52             reduce
0x105      2e             stop -> Fail


memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()

0x106      30             pop
0x107      67300a         get "0"                     ; 0x108
0x10a      67310a         get "1"                     ; 0x10b
0x10d      8c0b5f5f6765.  short_binunicode "__getitem__" ; 0x10f
// getattr()
// Flag
// __getitem__

0x11a      86             tuple2
0x11b      52             reduce
// getattr(Flag, __getitem__)
0x11c      94             memoize
0x11d      30             pop
0x11e      4d0000         binint2 0x0
0x121      94             memoize


# loop start
0x122      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0

0x123      67320a         get "2"                     ; 0x124
0x126      67330a         get "3"                     ; 0x127
0x129      67300a         get "0"                     ; 0x12a
0x12c      67360a         get "6"                     ; 0x12d
0x12f      67370a         get "7"                     ; 0x130
// jmp
// add()
// getattr()


0x132      85             tuple1
0x133      52             reduce
// jmp
// add()
// getattr()
// Flag[0], __le__

0x134      8c065f5f6c65.  short_binunicode "__le__"   ; 0x136
0x13c      86             tuple2
0x13d      52             reduce
// jmp
// add()
// getattr((Flag[0], __le__))

0x13e      4d7f00         binint2 0x7f                ; '\x7f'
0x141      85             tuple1
0x142      52             reduce
// jmp
// add()
// (Flag[0] <= 0x7f)
// 0x14a

0x143      4d4a01         binint2 0x14a
0x146      86             tuple2
0x147      52             reduce
0x148      85             tuple1
0x149      52             reduce
0x14a      2e             stop
// jmp
// add()
// (Flag[0] <= 0x7f)
// 0x14a

0x14b      30             pop


memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0

0x14c      67320a         get "2"                     ; 0x14d
0x14f      67330a         get "3"                     ; 0x150
0x152      67340a         get "4"                     ; 0x153
0x155      67350a         get "5"                     ; 0x156
0x158      67330a         get "3"                     ; 0x159
0x15b      67370a         get "7"                     ; 0x15c
0x15e      4d0100         binint2 0x1
0x161      86             tuple2
0x162      52             reduce
0x163      70370a         put "7"                     ; 0x164
// jmp
// add()
// mul()
// eq()
// 1

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 1

0x166      4d4000         binint2 0x40                ; '@'
0x169      86             tuple2
// jmp
// add()
// mul()
// eq()
// (1, 0x40)

0x16a      52             reduce
0x16b      4d5500         binint2 0x55                ; 'U'
0x16e      86             tuple2
0x16f      52             reduce
// jmp
// add()
// mul((eq(1, 0x40), 0x55))

0x170      4d2201         binint2 0x122
0x173      86             tuple2
// jmp
// add()
// (mul((eq(1, 0x40), 0x55)), 0x122)

# Loop until i reaches 0x40
# Verifies all characters are in the ASCII range
0x174      52             reduce
0x175      85             tuple1
0x176      52             reduce
// jmp
// add((mul((eq(1, 0x40), 0x55)), 0x122))


memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0x40

0x177      30             pop
0x178      67300a         get "0"                     ; 0x179
0x17b      67300a         get "0"                     ; 0x17c
0x17e      5d             empty_list
0x17f      94             memoize
// getattr()
// getattr()
// empty_list

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0x40
memo8: []

0x180      8c0661707065.  short_binunicode "append"   ; 0x182
0x188      86             tuple2
0x189      52             reduce
// getattr()
// getattr((empty_list, append))

0x18a      94             memoize

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0x40
memo8: []
memo9: append to memo8

0x18b      30             pop
// getattr()

0x18c      67380a         get "8"                     ; 0x18d
0x18f      8c0b5f5f6765.  short_binunicode "__getitem__" ; 0x191
0x19c      86             tuple2
0x19d      52             reduce
0x19e      94             memoize
// getattr([], __getitem__)

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0x40
memo8: []
memo9: append to memo8
memo10: fetch value from memo8 by index

0x19f      30             pop

0x1a0      67300a         get "0"                     ; 0x1a1
0x1a3      8c086275696c.  short_binunicode "builtins" ; 0x1a5
0x1ad      8c03696e74     short_binunicode "int"      ; 0x1af
0x1b2      93             stack_global
0x1b3      8c0a66726f6d.  short_binunicode "from_bytes" ; 0x1b5
0x1bf      86             tuple2
0x1c0      52             reduce
// getattr(int, from_bytes)

0x1c1      94             memoize
0x1c2      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0x40
memo8: []
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()

0x1c3      4d0000         binint2 0x0
0x1c6      70370a         put "7"                     ; 0x1c7


# loop
0x1c9      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0
memo8: []
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()


0x1ca      67390a         get "9"                     ; 0x1cb
0x1cd      6731310a       get "11"                    ; 0x1ce
0x1d1      67360a         get "6"                     ; 0x1d2
0x1d4      8c086275696c.  short_binunicode "builtins" ; 0x1d6
0x1de      8c05736c6963.  short_binunicode "slice"    ; 0x1e0
0x1e5      93             stack_global
0x1e6      67340a         get "4"                     ; 0x1e7
0x1e9      67370a         get "7"                     ; 0x1ea
0x1ec      4d0800         binint2 0x8
// m8.append()
// int.from_bytes()
// flag[x]
// slice
// mul()
// i = 0
// 0x8

0x1ef      86             tuple2
0x1f0      52             reduce
// m8.append()
// int.from_bytes()
// flag[x]
// slice
// mul(i, 0x8)

0x1f1      67340a         get "4"                     ; 0x1f2
0x1f4      67330a         get "3"                     ; 0x1f5
0x1f7      67370a         get "7"                     ; 0x1f8
0x1fa      4d0100         binint2 0x1
// m8.append()
// int.from_bytes()
// flag[x]
// slice
// mul(i, 0x8)
// mul()
// add()
// i = 0
// 1

0x1fd      86             tuple2
0x1fe      52             reduce
0x1ff      4d0800         binint2 0x8
0x202      86             tuple2
0x203      52             reduce
0x204      86             tuple2
0x205      52             reduce
0x206      85             tuple1

// m8.append()
// int.from_bytes()
// flag[x]
// slice[mul(i, 0x8), mul(add(i + 1), 8)]

0x207      52             reduce
// m8.append()
// int.from_bytes()
// flag[i*8:(i+1)*8]

0x208      8c066c697474.  short_binunicode "little"   ; 0x20a
0x210      86             tuple2
0x211      52             reduce
// m8.append()
// int.from_bytes(flag[i*8:(i+1)*8], little)

0x212      85             tuple1
0x213      52             reduce
0x214      30             pop

# Extract 8 bytes of flag at a time, convert to int, and append to m8
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0
memo8: []
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()


0x215      67320a         get "2"                     ; 0x216
0x218      67330a         get "3"                     ; 0x219
0x21b      67340a         get "4"                     ; 0x21c
0x21e      67350a         get "5"                     ; 0x21f
0x221      67330a         get "3"                     ; 0x222
0x224      67370a         get "7"                     ; 0x225
0x227      4d0100         binint2 0x1
0x22a      86             tuple2
0x22b      52             reduce
// jmp
// add()
// mul()
// eq()
// add(i + 1)

0x22c      70370a         put "7"                     ; 0x22d
0x22f      4d0800         binint2 0x8
0x232      86             tuple2
0x233      52             reduce
0x234      4d7700         binint2 0x77                ; 'w'
0x237      86             tuple2
0x238      52             reduce
0x239      4dc901         binint2 0x1c9
0x23c      86             tuple2
0x23d      52             reduce
# Overwrite i
// jmp
// add(mul(eq(add(i + 1), 0x8), 0x77), 0x1c9)

0x23e      85             tuple1
0x23f      52             reduce


# Extract 8 bytes of flag at a time, convert to int, and append to m8
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 8
memo8: [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()

# If i == 8, exit the loop
0x240      30             pop
0x241      67300a         get "0"                     ; 0x242
0x244      5d             empty_list
0x245      94             memoize
// getattr()
// empty_list

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 8
memo8: arr1 [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()
memo12: arr2 []

0x246      8c0661707065.  short_binunicode "append"   ; 0x248
0x24e      86             tuple2
0x24f      52             reduce
// getattr(empty_list, append)

0x250      94             memoize
0x251      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 8
memo8: arr1 [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()
memo12: arr2 []
memo13: append to memo12

0x252      67300a         get "0"                     ; 0x253
0x255      6731320a       get "12"                    ; 0x256
0x259      8c0b5f5f6765.  short_binunicode "__getitem__" ; 0x25b
0x266      86             tuple2
0x267      52             reduce
0x268      94             memoize
0x269      30             pop
memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 8
memo8: arr1 [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()
memo12: arr2 []
memo13: append to memo12
memo14: fetch value from memo12 by index


0x26a      67300a         get "0"                     ; 0x26b
0x26d      8c086275696c.  short_binunicode "builtins" ; 0x26f
0x277      8c03696e74     short_binunicode "int"      ; 0x279
0x27c      93             stack_global
0x27d      8c075f5f786f.  short_binunicode "__xor__"  ; 0x27f
0x286      86             tuple2
0x287      52             reduce
// getattr(int, xor)
0x288      94             memoize
0x289      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 8
memo8: arr1 [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()
memo12: arr2 []
memo13: append to memo12
memo14: fetch value from memo12 by index
memo15: int.xor()

0x28a      493132343434.  int "1244422970072434993"   ; 0x28b
0x29f      94             memoize
0x2a0      30             pop
0x2a1      4d0000         binint2 0x0
0x2a4      70370a         put "7"                     ; 0x2a5
0x2a7      30             pop

memo0: getattr()
memo1: Flag
memo2: dict.get(builtins.globals(), f).seek() -> jmp
memo3: add()
memo4: mul()
memo5: eq()
memo6: getattr(Flag, __getitem__)[x] -> likely fetches input character
memo7: i = 0
memo8: arr1 [<flag 8 bytes each, little-endian>]
memo9: append to memo8
memo10: fetch value from memo8 by index
memo11: int.from_bytes()
memo12: arr2 []
memo13: append to memo12
memo14: fetch value from memo12 by index
memo15: int.xor()
memo16: key = 1244422970072434993

0x2a8      6731330a       get "13"                    ; 0x2a9
0x2ac      8c086275696c.  short_binunicode "builtins" ; 0x2ae
0x2b6      8c03706f77     short_binunicode "pow"      ; 0x2b8
0x2bb      93             stack_global
// arr2.append()
// pow

0x2bc      6731350a       get "15"                    ; 0x2bd
0x2c0      6731300a       get "10"                    ; 0x2c1
0x2c4      67370a         get "7"                     ; 0x2c5
0x2c7      85             tuple1
0x2c8      52             reduce
0x2c9      6731360a       get "16"                    ; 0x2ca ; "16\n\x86RI65537\nI18446744073709551557\n\x87R\x85R0g14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01"
0x2cd      86             tuple2
0x2ce      52             reduce
// arr2.append()
// pow()
// xor(arr1[i], 1244422970072434993))


0x2cf      493635353337.  int "65537"                 ; 0x2d0
0x2d6      493138343436.  int "18446744073709551557"  ; 0x2d7
0x2ec      87             tuple3
0x2ed      52             reduce
// arr2.append()
// pow(
    (xor(arr1[i], 1244422970072434993), 65537, 18446744073709551557)
)

0x2ee      85             tuple1
0x2ef      52             reduce
0x2f0      30             pop
# Append result to memo12

0x2f1      6731340a       get "14"                    ; 0x2f2 ; "14\ng7\n\x85Rp16\n0g2\ng3\ng4\ng5\ng3\ng7\nM\x01"
0x2f5      67370a         get "7"                     ; 0x2f6
0x2f8      85             tuple1
// fetch value from memo12 by index
// i = 0

0x2f9      52             reduce
0x2fa      7031360a       put "16"                    ; 0x2fb
0x2fe      30             pop

# Use the result of the previous encryption as the next XOR key
0x2ff      67320a         get "2"                     ; 0x300
0x302      67330a         get "3"                     ; 0x303
0x305      67340a         get "4"                     ; 0x306
0x308      67350a         get "5"                     ; 0x309
0x30b      67330a         get "3"                     ; 0x30c
0x30e      67370a         get "7"                     ; 0x30f
0x311      4d0100         binint2 0x1
0x314      86             tuple2
0x315      52             reduce
0x316      70370a         put "7"                     ; 0x317
0x319      4d0800         binint2 0x8
0x31c      86             tuple2
0x31d      52             reduce
0x31e      4d8300         binint2 0x83
0x321      86             tuple2
0x322      52             reduce
0x323      4da702         binint2 0x2a7
0x326      86             tuple2
0x327      52             reduce
0x328      85             tuple1
0x329      52             reduce
0x32a      30             pop
0x32b      67300a         get "0"                     ; 0x32c
0x32e      6731320a       get "12"                    ; 0x32f
0x332      8c065f5f6571.  short_binunicode "__eq__"   ; 0x334 ; "__eq__\x86R(I8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x33a      86             tuple2
0x33b      52             reduce
0x33c      28             mark
0x33d      493832313533.  int "8215359690687096682"   ; 0x33e ; "8215359690687096682\nI1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x352      493138363236.  int "1862662588367509514"   ; 0x353 ; "1862662588367509514\nI8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x367      493833353037.  int "8350772864914849965"   ; 0x368 ; "8350772864914849965\nI11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x37c      493131363136.  int "11616510986494699232"  ; 0x37d ; "11616510986494699232\nI3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x392      493337313136.  int "3711648467207374797"   ; 0x393 ; "3711648467207374797\nI9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x3a7      493937323231.  int "9722127090168848805"   ; 0x3a8 ; "9722127090168848805\nI16780197523811627561\nI18138828537077112905\nl\x85R."
0x3bc      493136373830.  int "16780197523811627561"  ; 0x3bd ; "16780197523811627561\nI18138828537077112905\nl\x85R."
0x3d2      493138313338.  int "18138828537077112905"  ; 0x3d3
0x3e8      6c             list
0x3e9      85             tuple1
0x3ea      52             reduce
0x3eb      2e             stop

Deserialization via pickle operates as a VM.

The basic operations are only four: “push a value onto the stack”, “tuple-ify one or two stack values”, “execute code using two stack values (a callable tuple and an argument tuple)”, and “save a stack value into memo”.

As noted in my analysis comments above, by patiently working through the pattern of “push data → tupleify → call function” from top to bottom, I was ultimately able to identify that the code performs the following operations:

  1. Split the 64-character flag into 8-byte chunks, convert each chunk to an int in little-endian byte order, and store them in an array.
  2. Process each of the 8 int values with the following formula. (The initial key is 1244422970072434993; for subsequent iterations, the key is the result of the previous encryption.)

    pow((xor(arr1[i], key), 65537, 18446744073709551557))

  3. If the correct flag is entered, the computed results will match the hardcoded long values.

From the above, if we can decrypt the plaintext from the 8 hardcoded long values, we can obtain the flag.

The challenge here was how to determine the private key.

From the encryption logic, it is clearly RSA encryption.

However, the private key is not given, and 18446744073709551557 (acting as n) is not a product of primes, so I could not factor it into p and q.

After consulting with a team member, I got the spot-on suggestion to try pow(e, -1, n) as the private key for decryption.

Based on this idea, I created the following solver and successfully decrypted the ciphertext to obtain the correct flag.

from Crypto.Util.number import long_to_bytes

ans = [
    8215359690687096682,
    1862662588367509514,
    8350772864914849965,
    11616510986494699232,
    3711648467207374797,
    9722127090168848805,
    16780197523811627561,
    18138828537077112905
]

key = 1244422970072434993
e = 65537
n = 18446744073709551557
d = pow(65537, -1, 18446744073709551556)

flag = []
for i in range(1, 8):
    m = pow(ans[i], d, n)
    flag.append((m ^ ans[i-1]).to_bytes(8,'little'))

# SECCON{Can_someone_please_make_a_debugger_for_Pickle_bytecode??}

Incidentally, reading other people’s writeups, I found that using a tool called flickling yields a decompiled result like the following.

This is overwhelmingly easier to read, and I found myself wondering what all my hard work had been for.

_var0 = input('FLAG> ')
_var1 = getattr(_var0, 'encode')
_var2 = _var1()
_var3 = getattr(dict, 'get')
_var4 = globals()
_var5 = _var3(_var4, 'f')
_var6 = getattr(_var5, 'seek')
_var7 = getattr(int, '__add__')
_var8 = getattr(int, '__mul__')
_var9 = getattr(int, '__eq__')
_var10 = len(_var2)
_var11 = _var9(_var10, 64)
_var12 = _var7(_var11, 261)
_var13 = _var6(_var12)
_var14 = getattr(_var2, '__getitem__')
_var15 = _var14(0)
_var16 = getattr(_var15, '__le__')
_var17 = _var16(127)
_var18 = _var7(_var17, 330)
_var19 = _var6(_var18)
_var20 = _var7(0, 1)
_var21 = _var9(_var20, 64)
_var22 = _var8(_var21, 85)
_var23 = _var7(_var22, 290)
_var24 = _var6(_var23)
_var25 = getattr([], 'append')
_var26 = getattr([], '__getitem__')
_var27 = getattr(int, 'from_bytes')
_var28 = _var8(0, 8)
_var29 = _var7(0, 1)
_var30 = _var8(_var29, 8)
_var31 = slice(_var28, _var30)
_var32 = _var14(_var31)
_var33 = _var27(_var32, 'little')
_var34 = _var25(_var33)
_var35 = _var7(0, 1)
_var36 = _var9(_var35, 8)
_var37 = _var8(_var36, 119)
_var38 = _var7(_var37, 457)
_var39 = _var6(_var38)
_var40 = getattr([], 'append')
_var41 = getattr([], '__getitem__')
_var42 = getattr(int, '__xor__')
_var43 = _var26(0)
_var44 = _var42(_var43, 1244422970072434993)
_var45 = pow(_var44, 65537, 18446744073709551557)
_var46 = _var40(_var45)
_var47 = _var41(0)
_var48 = _var7(0, 1)
_var49 = _var9(_var48, 8)
_var50 = _var8(_var49, 131)
_var51 = _var7(_var50, 679)
_var52 = _var6(_var51)
_var53 = getattr([], '__eq__')
_var54 = _var53([8215359690687096682, 1862662588367509514, 8350772864914849965, 11616510986494699232, 3711648467207374797, 9722127090168848805, 16780197523811627561, 18138828537077112905])
result0 = _var54

Reference: SECCON CTF 2023 Quals Writeup - Qiita

That said, working through the hard-to-read assembly did feel like a small level-up, so I’ll call it a win.

Summary

35th domestically — the road to the national finals is still long, but I’ll keep grinding.