Unpacking PyArmor

July 29, 2020 // icecream

SoderCTF - Rev6

Greetings! This is a write-up that covers up the sixth reversing engineering challenge of SoderCTF.

Starting to Analyze

When you try to run the executable file it gives no output.

  1. > ./SierraTwo_V2.exe

Sometimes, it is a good idea to run grep command on files to check if there is a familiar error string

  1. [0x140008b14]> iz ~ Err
  2. 1 0x00021df8 0x1400233f8 38 39 .rdata ascii Error allocating decompression buffer\n
  3. 3 0x00021e28 0x140023428 26 27 .rdata ascii Error %d from inflate: %s\n
  4. 4 0x00021e48 0x140023448 30 31 .rdata ascii Error %d from inflateInit: %s\n
  5. 8 0x00021ec8 0x1400234c8 23 24 .rdata ascii Error decompressing %s\n
  6. 18 0x00021fa0 0x1400235a0 15 16 .rdata ascii Error on file\n.
  7. 21 0x00021fc8 0x1400235c8 35 36 .rdata ascii Error allocating memory for status\n
  8. 23 0x00022010 0x140023610 25 26 .rdata ascii Error opening archive %s\n
  9. 25 0x00022040 0x140023640 17 18 .rdata ascii Error copying %s\n
  10. 31 0x000220a8 0x1400236a8 20 21 .rdata ascii Error extracting %s\n
  11. 142 0x00022f38 0x140024538 31 32 .rdata ascii Error loading Python DLL '%s'.\n
  12. 154 0x00023110 0x140024710 34 35 .rdata ascii Error detected starting Python VM.
  13. 173 0x00023310 0x140024910 30 31 .rdata ascii Error creating child process!\n

The error string that shows up on 142nd made us think it was a pyinstaller.

  1. [0x140008b14]> iz ~ PyInstaller
  2. 177 0x00023370 0x140024970 35 36 .rdata ascii PyInstaller: FormatMessageW failed.
  3. 178 0x00023398 0x140024998 44 45 .rdata ascii PyInstaller: pyi_win32_utils_to_utf8 failed.

We got right in our guess. If it really uses pyinstaller, we can extract the files inside with pyinstxtractor tool.

  1. > python3.8 pyinstxtractor.py SierraTwo_V2.exe
  2. [+] Processing SierraTwo_V2.exe
  3. [+] Pyinstaller version: 2.1+
  4. [+] Python version: 38
  5. [+] Length of package: 10795430 bytes
  6. [+] Found 976 files in CArchive
  7. [+] Beginning extraction...please standby
  8. [+] Possible entry point: pyiboot01_bootstrap.pyc
  9. [+] Possible entry point: pyi_rth__tkinter.pyc
  10. [+] Possible entry point: pyi_rth_multiprocessing.pyc
  11. [+] Possible entry point: SierraTwo.pyc
  12. [+] Found 399 files in PYZ archive
  13. [+] Successfully extracted pyinstaller archive: SierraTwo_V2.exe
  14. You can now use a python decompiler on the pyc files within the extracted directory

It states that it could not find the pytransform module when we wanted to run the file we extracted.

  1. SierraTwo_V2.exe_extracted> python3.8 SierraTwo.pyc
  2. Traceback (most recent call last):
  3. File "dist\obf\SierraTwo.py", line 2, in <module>
  4. ModuleNotFoundError: No module named 'pytransform'

pytransform name is actually a familiar name, this is pyarmor, pyarmor is a python bytecode packer. We can quickly grep and confirm this.

  1. >> grep -rn pyarmor
  2. Binary file PYZ-00.pyz_extracted/config.pyc matches
  3. Binary file PYZ-00.pyz_extracted/pytransform.pyc matches
  4. Binary file SierraTwo.pyc matches
  5. Binary file _pytransform.dll matches

The Action

We can start by creating the file hierarchy in its own documentation.

  1. SierraTwo.pyc
  2. pytransform/
  3. __init__.py
  4. _pytransform.so
We will change __init__.py file with pytransform.pyc

Then, we should run this file flawlessly.

  1. Traceback (most recent call last):
  2. File "<dist\obf\sierratwo.py>", line 4, in <module>
  3. File "<frozen sierratwo="">", line 21, in <module>
  4. ModuleNotFoundError: No module named 'slack'
  5. </module></frozen></module></dist\obf\sierratwo.py>

We are so lucky! The code has a library dependency.
`Library dependency == Library Hijacking`
We can create a file named as 'slack.py' then we can put codes that we want. First of all because the code that we will inject will be imported, we need to go to the relevant frame in callstack and look around.

Hurray! Let's check the frames first!

Note:
It will be done with inspection to be more informative here.
`import pdb; pdb.set_trace()` also can be used.

  1. import inspect
  2. for frameinfo in inspect.stack():
  3. print(frameinfo.frame)
  1. >
  2. ', line 219, code _call_with_frames_removed>
  3. ', line 783, code exec_module>
  4. ', line 671, code _load_unlocked>
  5. ', line 975, code _find_and_load_unlocked>
  6. ', line 991, code _find_and_load>
  7. ', line 21, code <module>>
  8. >
  9. </module>

The related frame that must be analyzed, is probably '0x000001FA7D4971F0' named as SierraTwo.

  1. frame = inspect.stack()[6]

The variable names and constant values come with the code object to be used in bytecode. That is, constant values (like functions) can still be inside our code.

  1. c = frame.f_code
  2. for idx, obj in enumerate(c.co_consts):
  3. if inspect.iscode(obj):
  4. print(idx, obj.co_name)
  1. 5 prepare_shell
  2. 7 create_channel
  3. 9 next_channel
  4. 11 machine_info
  5. 15 uploader_thread
  6. 17 upload
  7. 19 respawn
  8. 21 handle_user_input
  9. 23 commands
  10. 25 listen
  11. 27 hide_process
  12. 32 protect_pytransform

If we can read the variables in these functions, maybe we can get something useful.

  1. for idx, obj in enumerate(c.co_consts):
  2. if inspect.iscode(obj):
  3. print(idx, obj.co_name, obj.co_consts)
  1. 5 prepare_shell (None, 'channels', 'channel', 'id', ('channel', 'users'), ('channel', 'text'), ('channel',), 'messages', 0, 'ts', ('channel', 'timestamp'))
  2. 7 create_channel (None, ('name',))
  3. 9 next_channel (None, 0, 'name', '-', 2, 1)
  4. 11 machine_info (None, '', 'Windows', 'wmic csproduct get UUID', ' ', 5, 'Linux', 'cat', '/etc/machine-id', 'Darwin', 'ioreg', '-d2', '-c', 'IOPlatformExpertDevice', '|', 'awk', '-F', "'/IOPlatformUUID/{print $(NF-1)}'", 'unknown', '`', '` with the `', '` UUID connected.')
  5. 15 uploader_thread (None, '', True, ('file', 'channels', 'filename', 'title'), 'Uploaded `', '`', 'File not found.', False)
  6. 17 upload (None, True, ('target', 'daemon', 'args'), 'Please wait while your file is uploaded.', ('channel', 'text'), 'Cannot start uploader thread')
  7. 19 respawn (None, '', 'b0', '9b', 'b1', '21', '6e', '10', '40', '3c', '72', '23', '6f', '16', 'af', '14', '1a', '64', '12', 'c8', '5b', 'a6', '69', 'b4', '6d', '74', 'a2', 'cf', '62', '52', '58', '30', 'e0', '65', 'db', '1e', '56', '73', 'c4', 'c2', '7e', '20', '24', '8e', 'ce', '42', '17', '9a', '87', 'fb', 'dd', 'eb', 'ea', '25', 'aa', 'fa', 'a9', '2e', '78', 'de', '66', '00', '85', 'dc', '36', '32', '59', 'ae', '3a', '2b', '29', 'da', 'd5', 'f5', '2f', '2c', 'fe', 'cd', '2a', '90', 'e6', '18', '75', '26', '68', '2d', 'be', '35', 'd0', '5a', '31', '22', '5f', '76', '27', 'e1', '91', '45', '63', '94', '5d', '4c', '15', 'ff', 'e2', '5e', '28', '61', 'ee', 'f6', '08', 'c3', 'c7', 'b6', 'b5', '02', '55', 'ef', 'd9', '04', 'ad', code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "frozen="" sierratwo=""", line 400>, 'respawn.<locals>.varxor', '__main__', 1, 37, 'a', 'b', 'z')
  8. 21 handle_user_input (None, '', 'Error reading command output.', ('channel', 'text'), 'The command did not return anything.', '`', 'Output contains an illegal character.', 0, code object="" listcomp="" at 0x0000023226DFF660, file "frozen sierratwo=""", line 440>, 'handle_user_input.locals.<listcomp>', '```', 'Output size is too big. If you are trying to read a file, try uploading it.', 'Unknown error.')
  9. 23 commands (None, 'upload', ' ', 1, 'cd', '`cd` complete.', ('channel', 'text'), 'shell_exit', 0)
  10. 25 listen (None, '', 'randomval', ('channel',), 0.8, 'messages', 0, 'client_msg_id', 'text', 0.3)
  11. 27 hide_process (None, 0, 'taskkill /PID ', ' /f')
  12. 32 protect_pytransform (None, 0, code object="" assert_builtin="" at="" 0x0000023226dff9d0,="" file="" "frozen="" sierratwo="">", line 530>, 'protect_pytransform.locals.assert_builtin', code object="" check_obfuscated_script="" at="" 0x0000023226dffb30,="" file="" "frozen="" sierratwo=""", line 536>, 'protect_pytransform.<locals>.check_obfuscated_script', code object="" check_mod_pytransform="" at="" 0x0000023226dffc90,="" file="" "frozen="" sierratwo="">", line 545>, 'protect_pytransform.<locals>.check_mod_pytransform', code object="" check_lib_pytransform="" at="" 0x0000023226dffd40,="" file="" "<frozen="" sierratwo="">", line 559>, 'protect_pytransform.<locals>.check_lib_pytransform')
  13. <code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "<frozen="" sierratwo=""><code object="" listcomp=""code object="" assert_builtin="" at="" 0x0000023226dff9d0,="" file="" "<frozen="" sierratwo=""code object="" check_obfuscated_script="" at="" 0x0000023226dffb30,="" file="" "<frozen="" sierratwo=""code object="" check_mod_pytransform="" at="" 0x0000023226dffc90,="" file="" "frozen="" sierratwo=""code object="" check_lib_pytransform="" at="" 0x0000023226dffd40,="" file="" "frozen="" sierratwo=""
  14. code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "frozen="" sierratwo=""code object="" listcomp=""code object="" assert_builtin="" at="" 0x0000023226dff9d0,="" file="" "<frozen="" sierratwo=""code object="" check_obfuscated_script="" at="" 0x0000023226dffb30,="" file="" "<frozen="" sierratwo=""code object="" check_mod_pytransform="" at="" 0x0000023226dffc90,="" file="" "<frozen="" sierratwo=""code object="" check_lib_pytransform="" at="" 0x0000023226dffd40,="" file="" "<frozen="" sierratwo=""code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "<frozen="" sierratwo=""code object="" listcomp=""code object="" assert_builtin="" at="" 0x0000023226dff9d0,="" file="" "<frozen="" sierratwo=""code object="" check_obfuscated_script="" at="" 0x0000023226dffb30,="" file="" "<frozen="" sierratwo=""code object="" check_mod_pytransform="" at="" 0x0000023226dffc90,="" file="" "frozen="" sierratwo=""code object="" check_lib_pytransform="" at="" 0x0000023226dffd40,="" file="" "frozen="" sierratwo=""
  15. It looks like we have found suspicious data...
  16. 19 respawn (None, '', 'b0', '9b', 'b1', '21', '6e', '10', '40', '3c', '72', '23', '6f', '16', 'af', '14', '1a', '64', '12', 'c8', '5b', 'a6', '69', 'b4', '6d', '74', 'a2', 'cf', '62', '52', '58', '30', 'e0', '65', 'db', '1e', '56', '73', 'c4', 'c2', '7e', '20', '24', '8e', 'ce', '42', '17', '9a', '87', 'fb', 'dd', 'eb', 'ea', '25', 'aa', 'fa', 'a9', '2e', '78', 'de', '66', '00', '85', 'dc', '36', '32', '59', 'ae', '3a', '2b', '29', 'da', 'd5', 'f5', '2f', '2c', 'fe', 'cd', '2a', '90', 'e6', '18', '75', '26', '68', '2d', 'be', '35', 'd0', '5a', '31', '22', '5f', '76', '27', 'e1', '91', '45', '63', '94', '5d', '4c', '15', 'ff', 'e2', '5e', '28', '61', 'ee', 'f6', '08', 'c3', 'c7', 'b6', 'b5', '02', '55', 'ef', 'd9', '04', 'ad', code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "frozen="" sierratwo="">", line 400>, 'respawn.<locals>.varxor', '__main__', 1, 37, 'a', 'b', 'z')
  17. code object="" varxor="" at="" 0x0000023226dff5b0,="" file="" "frozen="" sierratwo="">

After all, this function is a codeobject, which means we can run with exec() function.

  1. frame = inspect.stack()[6].frame
  2. respawn = frame.f_code.co_consts[19]
  3. print(exec(respawn))
  4. None

Sadly this function does not return anything.
We can dissasemble this bytecode with dis (https://docs.python.org/3/library/dis.html) library.

  1. import dis
  2. dis.dis(respawn)
  1. 167 0 JUMP_ABSOLUTE 18
  2. 2 NOP
  3. 168 4 NOP
  4. >> 6 POP_BLOCK
  5. 169 8 BEGIN_FINALLY
  6. 10 NOP
  7. 170 12 NOP
  8. 14 EXTENDED_ARG 4
  9. 171 16 JUMP_ABSOLUTE 1066
  10. >> 18 LOAD_GLOBAL 2 (__armor_enter__)
  11. 172 20 CALL_FUNCTION 0
  12. 22 POP_TOP
  13. 173 24 NOP
  14. 26 NOP
  15. 174 28 EXTENDED_ARG 4
  16. 30 SETUP_FINALLY 1034 (to 1066)
  17. 175 32 INPLACE_POWER
  18. 34 NOP
  19. 176 >> 36 MAP_ADD 40
  20. 38 <222> 175
  21. ...
  22. ...
  23. ...
  24. ...
  25. Traceback (most recent call last):
  26. File "dist\obf\sierratwo.py", line 4, in <module>
  27. File "frozen sierratwo=""", line 21, in <module>
  28. File "ayskrem\slack.py", line 38, in <module>
  29. dis.dis(respawn)
  30. File "xi\git\c\cpython\lib\dis.py", line 79, in dis
  31. _disassemble_recursive(x, file=file, depth=depth)
  32. File "xi\git\c\cpython\lib\dis.py", line 373, in _disassemble_recursive
  33. disassemble(co, file=file)
  34. File "xi\git\c\cpython\lib\dis.py", line 369, in disassemble
  35. _disassemble_bytes(co.co_code, lasti, co.co_varnames, co.co_names,
  36. File "xi\git\c\cpython\lib\dis.py", line 401, in _disassemble_bytes
  37. for instr in _get_instructions_bytes(code, varnames, names,
  38. File "xi\git\c\cpython\lib\dis.py", line 340, in _get_instructions_bytes
  39. argval, argrepr = _get_name_info(arg, names)
  40. File "xi\git\c\cpython\lib\dis.py", line 304, in _get_name_info
  41. argval = name_list[name_index]
  42. IndexError: tuple index out of range

Unfortunately we were disappointed again.
If we look at the documentation of PyArmor, we can see that this is the wrap-mode (https://pyarmor.readthedocs.io/en/latest/mode.html?highlight=__armor_enter__#wrap-mode) feature.

  1. LOAD_GLOBALS N (__armor_enter__) N = length of co_consts
  2. CALL_FUNCTION 0
  3. POP_TOP
  4. SETUP_FINALLY X (jump to wrap footer) X = size of original byte code
  5. Here it's obfuscated bytecode of original function
  6. LOAD_GLOBALS N + 1 (__armor_exit__)
  7. CALL_FUNCTION 0
  8. POP_TOP
  9. END_FINALLY

According to the structure, '__armor_enter__' gets its own reference and updates the bytecode range.

In this case, there are two things we can do. The first one is, patching cpython and the other option is to reverse the extention/library. Most likely it will be easier and faster to patch the cpython.

Bytecodes are processed on `ceval.c`. Since our problem is a polymorphic codeobject, we will only dump the bytecodes that are running. So, we have to dump bytecodes just before switch_case.

  1. @@ -759,6 +759,11 @@ _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
  2. _Py_atomic_int * const eval_breaker = &ceval->eval_breaker;
  3. PyCodeObject *co;
  4. + FILE *a_logger;
  5. + int logger_flag;
  6. + if ((logger_flag = PyUnicode_CompareWithASCIIString(f->f_code->co_name, "respawn") == 0)) {
  7. + a_logger = fopen("xi\respawn.bin", "wb");
  8. + }
  9. /* when tracing we set things up so that
  10. not (instr_lb <= current_bytecode_offset < instr_ub)
  11. @@ -1077,9 +1082,12 @@ _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
  12. /* Start of code */
  13. /* push frame */
  14. - if (Py_EnterRecursiveCall(""))
  15. + if (Py_EnterRecursiveCall("")){
  16. + if (logger_flag) {
  17. + fclose(a_logger);
  18. + }
  19. return NULL;
  20. -
  21. + }
  22. tstate->frame = f;
  23. if (tstate->use_tracing) {
  24. @@ -1319,6 +1327,16 @@ main_loop:
  25. }
  26. }
  27. #endif
  28. + if (logger_flag) {
  29. + if (HAS_ARG(opcode)) {
  30. + fputc(opcode, a_logger);
  31. + fputc(oparg, a_logger);
  32. + }
  33. + else {
  34. + fputc(opcode, a_logger);
  35. + fputc(0, a_logger);
  36. + }
  37. + }
  38. switch (opcode) {
  39. @@ -3813,7 +3831,9 @@ exit_eval_frame:
  40. Py_LeaveRecursiveCall();
  41. f->f_executing = 0;
  42. tstate->frame = f->f_back;
  43. -
  44. + if (logger_flag){
  45. + fclose(a_logger);
  46. + }
  47. return _Py_CheckFunctionResult(NULL, retval, "PyEval_EvalFrameEx");
  48. }

We updated our code. We have to run respawn function again.

  1. exec(respawn)

Now, we can check `respawn.bin` file

  1. import dis
  2. dis.dis(respawn.replace(co_code=open("respawn.bin", "rb").read()), depth=0)
  1. 167 0 JUMP_ABSOLUTE 18
  2. 2 LOAD_GLOBAL 2 (__armor_enter__)
  3. 168 4 CALL_FUNCTION 0
  4. >> 6 POP_TOP
  5. 169 8 NOP
  6. 10 NOP
  7. 170 12 EXTENDED_ARG 4
  8. 14 SETUP_FINALLY 1034 (to 1050)
  9. 171 16 LOAD_CONST 1 ('')
  10. >> 18 STORE_FAST 0 (z2)
  11. 172 20 LOAD_CONST 2 ('b0')
  12. 22 STORE_FAST 1 (t6)
  13. 173 24 LOAD_CONST 3 ('9b')
  14. 26 STORE_FAST 2 (f23)
  15. 174 28 LOAD_CONST 4 ('b1')
  16. 30 STORE_FAST 3 (u46)
  17. ...
  18. ...
  19. ...
  20. 938 STORE_FAST 229 (a67)
  21. 940 LOAD_CONST 121 ((<code object="" varxor="" at="" 0x000001e90f0ff5b0,="" file="" "<frozen="" sierratwo="">", line 400>)
  22. 407 942 LOAD_CONST 122 ('respawn.<locals>.varxor')
  23. 944 MAKE_FUNCTION 0
  24. 408 946 STORE_FAST 230 (varxor)
  25. 948 LOAD_GLOBAL 0 (__name__)
  26. 950 LOAD_CONST 123 ('__main__')
  27. 952 COMPARE_OP 2 (==)
  28. 954 EXTENDED_ARG 4
  29. 409 956 POP_JUMP_IF_FALSE 1062
  30. 958 LOAD_CONST 0 (None)
  31. 960 JUMP_ABSOLUTE 6
  32. 962 POP_BLOCK
  33. 964 BEGIN_FINALLY
  34. 966 NOP
  35. 968 NOP
  36. 970 EXTENDED_ARG 4
  37. 410 972 JUMP_ABSOLUTE 1066
  38. 974 LOAD_GLOBAL 3 (__armor_exit__)
  39. 976 CALL_FUNCTION 0
  40. 978 POP_TOP
  41. 980 END_FINALLY
  42. 982 RETURN_VALUE
  43. code object="" varxor="" at="" 0x000001e90f0ff5b0,="" file="" "<frozen="" sierratwo=""

We have to check `varxor` function but it is also packed. We decided to check manually, because we are lazy.
After all, we can replace the code what original `varxor` function has with another function and we can get what it returns.

  1. def varxor(a, b): pass
  2. varxor.__code__ = respawn.co_consts[respawn.co_consts.index('ad') + 1]
  3. >>> varxor(1, 2)
  4. *** TypeError: 'int' object is not iterable
  5. >>> varxor('1', '2')
  6. '3'
  7. >>> varxor('23123AB', 'F0')
  8. 'd3'
  9. >>> varxor('23123AB', '')
  10. ''

It looks like it does simple hexstring xor operation. It looks legit for now. If there would be problem, we can go deeper.

  1. lambda a, b: a and b and hex(int(a, 16) ^ int(b, 16))[2:]

After creating a lot of variable, the main section is appeared. It checks if it is called directly.

  1. exec(respawn, exec(respawn, {"__name__": "__main__"}))
  2. dis.dis(respawn.replace(co_code=open("respawn.bin", "rb").read()), depth=0)

(Warning! This is not full dump, only missing part is printed)

  1. 408 946 STORE_FAST 230 (varxor)
  2. 948 LOAD_GLOBAL 0 (__name__)
  3. 950 LOAD_CONST 123 ('__main__')
  4. 952 COMPARE_OP 2 (==)
  5. 954 EXTENDED_ARG 4
  6. 409 956 POP_JUMP_IF_FALSE 1062
  7. 958 LOAD_CONST 124 (1)
  8. 960 STORE_FAST 231 (i)
  9. 962 LOAD_FAST 231 (i)
  10. 964 LOAD_CONST 125 (37)
  11. 966 COMPARE_OP 1 (<=)
  12. 968 EXTENDED_ARG 4
  13. 970 POP_JUMP_IF_FALSE 1062
  14. 410 972 LOAD_GLOBAL 1 (locals)
  15. 974 CALL_FUNCTION 0
  16. 976 LOAD_CONST 126 ('a')
  17. >> 978 LOAD_FAST 231 (i)
  18. 980 FORMAT_VALUE 0
  19. 982 BUILD_STRING 2
  20. 984 BINARY_SUBSCR
  21. 986 STORE_FAST 232 (a)
  22. 411 988 LOAD_GLOBAL 1 (locals)
  23. 990 CALL_FUNCTION 0
  24. 992 LOAD_CONST 127 ('b')
  25. 994 LOAD_FAST 231 (i)
  26. 996 FORMAT_VALUE 0
  27. 998 BUILD_STRING 2
  28. 1000 BINARY_SUBSCR
  29. 1002 STORE_FAST 233 (b)
  30. 413 1004 LOAD_GLOBAL 1 (locals)
  31. 1006 CALL_FUNCTION 0
  32. 1008 LOAD_CONST 128 ('z')
  33. 1010 LOAD_FAST 231 (i)
  34. 1012 FORMAT_VALUE 0
  35. 1014 BUILD_STRING 2
  36. 1016 BINARY_SUBSCR
  37. 415 1018 STORE_FAST 234 (z)
  38. 1020 LOAD_FAST 234 (z)
  39. 1022 LOAD_FAST 230 (varxor)
  40. 1024 LOAD_FAST 232 (a)
  41. 1026 LOAD_FAST 233 (b)
  42. 1028 CALL_FUNCTION 2
  43. 1030 INPLACE_ADD
  44. 1032 STORE_FAST 234 (z)
  45. 1034 LOAD_FAST 231 (i)
  46. 1036 LOAD_CONST 124 (1)
  47. 1038 INPLACE_ADD
  48. 1040 STORE_FAST 231 (i)
  49. 1042 EXTENDED_ARG 3
  50. 1044 JUMP_ABSOLUTE 978

The estimated equivalent of bytecode is as follows.

  1. while i <= 37:
  2. a = locals()[f"a{i}"]
  3. b = locals()[f"b{i}"]
  4. z = locals()[f"z{i}"]
  5. z += varxor(a, b)
  6. i += 1

`locals` is dynamic structure, but only one assignment to variable is required. It can be constructed from the code by using the function `get_instruction` from the `dis` library.

  1. import opcode
  2. from contextlib import suppress
  3. STORE_FAST = opcode.opmap["STORE_FAST"]
  4. LOAD_CONST = opcode.opmap["LOAD_CONST"]
  5. result = ""
  6. _locals = dict()
  7. for instruction in dis.get_instructions(respawn.replace(co_code=open("respawn.bin", "rb").read())):
  8. if instruction.opcode == STORE_FAST:
  9. _locals[instruction.argval] = last.argval
  10. elif instruction.opcode == LOAD_CONST:
  11. last = instruction
  12. for i in range(1, 37):
  13. a = _locals[f"a{i}"]
  14. b = _locals[f"b{i}"]
  15. z = _locals[f"z{i}"]
  16. result += z + (a and b and hex(int(a, 16) ^ int(b, 16))[2:])
  17. print(bytearray.fromhex(result[:-1]).decode())

The flag is finally here!

  1. flag{S3ri0uSlY_Y0u_s0Lv3D_tH1s_t00}

By the way, I am grateful to `big code` for learning cpython that I spent most of my day.