Jump to content

Python Patchme (Custom VM)


svenskithesource
Go to solution Solved by Extreme Coders,

Recommended Posts

svenskithesource

Python Patchme (Custom VM)


Hello,
Since the use of vm's is quite popular in .net obfuscation I thought it would be interesting to make one in Python. Hereby I release my first crackme using my VM. It's a very simple VM so it shouldn't be that hard to reverse with the right knowledge.
This is purely experimental to see if it would be useful somehow. I also used my own protector that adds cflow, so expect to see a lot of jumps.

Goal: Patch it (ultimately devirtualize it, but that's not necessary)


 

Link to comment
Extreme Coders
Posted (edited)

Not necessary to devirtualize to get the password. Everything is in the clear.

As a result patching was unnecessary towards the end goal.

Correct password:

Spoiler

yougottapatchit

image.png.7633814ce0d5721f6d1368336479572a.png

Steps

Spoiler
  1. This is a pyinstaller generated executable in Python 3.9. First step is to extract using pyinstxtractor.
    image.png.a12e37600bae6b29b470634c1e4b0e23.png
     
  2. The description mentions the crackme uses a custom VM. However we don't want to analyze the VM yet. The idea is to trace the instructions executed by the VM and check if something is compared to the entered password. That something should hopefully be the correct password. Python 3.7 and above support instruction tracing through sys.settrace but we are going to use the x-python instead. x-python is a CPython bytecode interpreter in pure python and useful for cases like this.
     
  3. Lets run the crackme using "tuts4you" as the password. 
    xpython writes the trace log to stderr. Hence we redirect stderr to stdout by 2>&1 to make it a one-liner. Finally, we can grep for "tuts4you" in the log. This gets us the correct password against which the input is compared.
    image.png.6607aef0bfa185255d481bc2238e185c.png

 

 

Edited by Extreme Coders
  • Like 1
  • Thanks 1
Link to comment
svenskithesource
10 hours ago, Extreme Coders said:

Not necessary to devirtualize to get the password. Everything is in the clear.

As a result patching was unnecessary towards the end goal.

Correct password:

  Reveal hidden contents

yougottapatchit

image.png.7633814ce0d5721f6d1368336479572a.png

Steps

  Reveal hidden contents
  1. This is a pyinstaller generated executable in Python 3.9. First step is to extract using pyinstxtractor.
    image.png.a12e37600bae6b29b470634c1e4b0e23.png
     
  2. The description mentions the crackme uses a custom VM. However we don't want to analyze the VM yet. The idea is to trace the instructions executed by the VM and check if something is compared to the entered password. That something should hopefully be the correct password. Python 3.7 and above support instruction tracing through sys.settrace but we are going to use the x-python instead. x-python is a CPython bytecode interpreter in pure python and useful for cases like this.
     
  3. Lets run the crackme using "tuts4you" as the password. 
    xpython writes the trace log to stderr. Hence we redirect stderr to stdout by 2>&1 to make it a one-liner. Finally, we can grep for "tuts4you" in the log. This gets us the correct password against which the input is compared.
    image.png.6607aef0bfa185255d481bc2238e185c.png

 

 

Hi, thanks for attempting my patchme, however like you mentioned everything is in the clear because the goal is not to find the password but to patch it so every password is seen as correct. Could you try again and patch it?

Link to comment
Extreme Coders
Posted (edited)
8 hours ago, svenskithesource said:

Could you try again and patch it?

Patching is nothing difficult either but since you asked.

Similar to x86, we can just just reverse the conditional jump so that it will show the success message for every password except the correct one.

We can search for "tuts4you" in the trace file.

INFO:xpython.vm:           @1580: LOAD_FAST OO000O0O0O00O0OO0
INFO:xpython.vm:           @1582: LOAD_FAST O0OOOOOO0O00OO0O0
INFO:xpython.vm:           @1584: COMPARE_OP ('yougottapatchit', 'tuts4you') ==
INFO:xpython.vm:           @1588: POP_JUMP_IF_FALSE 1594
INFO:xpython.vm:           @1594: LOAD_CONST 0
INFO:xpython.vm:           @1596: CALL_METHOD 1

The POP_JUMP_IF_FALSE instruction is what that decides to take the success or the failure branch. The instruction jumps to the target address if the comparison fails.

We can always patch the instruction to POP_JUMP_IF_TRUE.  However ironically in that case the crackme would show the success message for every password except the correct one.

Instead we can NOP out the instruction so that it doesn't take the jump irrespective of the comparision.

The instruction

POP_JUMP_IF_FALSE 1594

actually encodes to

90 06 72 3A

Python 3.9 opcodes can be found here: https://github.com/python/cpython/blob/v3.9.6/Include/opcode.h

1594 in hex is 0x63a. 
Python 3.6 and above uses 2 bytes per instruction consisting of the opcode and the operand both of which are byte sized,
In this case the operand 0x63a is too large to fit in 1 byte. Hence it has to be broken down into two instructions -

SETUP_EXTENDED 0x6
POP_JUMP_IF_FALSE 0x3a

with the final jump address calculated as (0x6<<8) | 0x3a = 0x63a = 1594

Searching for "90 06 72 3A" in the pyc file, we get exactly one hit.

Spoiler

image.png.459595851ae4d85fcd0d4426924a3778.png

We want to patch the instruction to
 

POP_TOP
NOP

which encodes to

01 00 09 00

The POP_TOP instruction is necessary to balance the stack as POP_JUMP_IF_FALSE also does pop the stack before deciding to jump or not.

After patching main.pyc

90 06 72 3A => 01 00 09 00

it will show success message for every password.

Patched pyc file attached:

main obf_patched.pyc

Edited by Extreme Coders
  • Like 3
  • Thanks 1
Link to comment
svenskithesource
On 5/23/2022 at 3:00 PM, Extreme Coders said:

Patching is nothing difficult either but since you asked.

Similar to x86, we can just just reverse the conditional jump so that it will show the success message for every password except the correct one.

We can search for "tuts4you" in the trace file.

INFO:xpython.vm:           @1580: LOAD_FAST OO000O0O0O00O0OO0
INFO:xpython.vm:           @1582: LOAD_FAST O0OOOOOO0O00OO0O0
INFO:xpython.vm:           @1584: COMPARE_OP ('yougottapatchit', 'tuts4you') ==
INFO:xpython.vm:           @1588: POP_JUMP_IF_FALSE 1594
INFO:xpython.vm:           @1594: LOAD_CONST 0
INFO:xpython.vm:           @1596: CALL_METHOD 1

The POP_JUMP_IF_FALSE instruction is what that decides to take the success or the failure branch. The instruction jumps to the target address if the comparison fails.

We can always patch the instruction to POP_JUMP_IF_TRUE.  However ironically in that case the crackme would show the success message for every password except the correct one.

Instead we can NOP out the instruction so that it doesn't take the jump irrespective of the comparision.

The instruction

POP_JUMP_IF_FALSE 1594

actually encodes to

90 06 72 3A

Python 3.9 opcodes can be found here: https://github.com/python/cpython/blob/v3.9.6/Include/opcode.h

1594 in hex is 0x63a. 
Python 3.6 and above uses 2 bytes per instruction consisting of the opcode and the operand both of which are byte sized,
In this case the operand 0x63a is too large to fit in 1 byte. Hence it has to be broken down into two instructions -

SETUP_EXTENDED 0x6
POP_JUMP_IF_FALSE 0x3a

with the final jump address calculated as (0x6<<8) | 0x3a = 0x63a = 1594

Searching for "90 06 72 3A" in the pyc file, we get exactly one hit.

  Reveal hidden contents

image.png.459595851ae4d85fcd0d4426924a3778.png

We want to patch the instruction to
 

POP_TOP
NOP

which encodes to

01 00 09 00

The POP_TOP instruction is necessary to balance the stack as POP_JUMP_IF_FALSE also does pop the stack before deciding to jump or not.

After patching main.pyc

90 06 72 3A => 01 00 09 00

it will show success message for every password.

Patched pyc file attached:

main obf_patched.pyc 6.61 kB · 4 downloads

Hello, thanks again for your detailed solution. I released an update and would appreciate if you would try it.

Link to comment
  • Solution
Extreme Coders
Teddy Rogers
This post was recognized by Teddy Rogers!

Extreme Coders was awarded the badge 'Great Content' and 1 points.

16 hours ago, svenskithesource said:

released an update

Nice update.

Now the jump or the comparison cannot be nopped easily as the same instruction is also executed for other purpose.

The COMPARE_OP instruction is executed three times in total

COMPARE_OP ('yougottapatchit', 'tuts4you') ==
COMPARE_OP (2, 1) ==
COMPARE_OP (1, 1) ==

But it can always be patched, details below.

The idea is to make changes so that the three comparisons always return true, false and true respectively.

Patch 1
=======

In a hex editor search and replace the "yougottapatchit" to 15 null bytes
"yougottapatchit" => "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" (15 null bytes)

 

Patch 2
=======

Search for the following sequence of bytes and replace with the corresponding
"7C 04 7C 05 6B 02" => "7C 05 7C 04 6B 05"

Explanation

Patch 1 changes the correct password to a string of 15 null bytes.

Patch 2 makes the following change to the comparison as shown. Originally the relevant instructions were:

7C 04    LOAD_FAST 0x4
7C 05    LOAD_FAST 0x5
6B 02    COMPARE_OP 0x2 ('==')
90 06    SETUP_EXTENDED 0x6
72 86    POP_JUMP_IF_FALSE 0x86

In pseudo-code,

if (var1 == var2) {...}

The patch changes it to

7C 05    LOAD_FAST 0x5
7C 04    LOAD_FAST 0x4
6B 05    COMPARE_OP 0x5 ('>=')
90 06    SETUP_EXTENDED 0x6
72 86    POP_JUMP_IF_FALSE 0x86

In pseudo-code,

if (var2 >= var1) {...}

 

As a result of the patches, the 3 COMPARE_OP instructions execute as

COMPARE_OP ('tuts4you', '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') >=
COMPARE_OP (1, 2) >=
COMPARE_OP (1, 1) >=

This returns true, false, true exactly in the order we want.

One downside of the patch is it will show the success message for every non-empty password. That is have to enter something as the password.

main obf v1.2-patched1.pyc

---------------

We can go further and patch it in such a way to make it also accept empty passwords.

We have to replace "yougottapatchit" with an empty string in the pyc file, however we cant just delete the bytes yet as that will corrupt the file.

The string "yougottapatchit" is actually stored within a tuple in the pyc file.

00000A80                                      29 06 7A 37              ).z7
00000A90  4D 61 64 65 20 62 79 20 73 76 65 6E 73 6B 69 74  Made by svenskit
00000AA0  68 65 73 6F 75 72 63 65 23 32 38 31 35 0A 47 6F  hesource#2815.Go
00000AB0  61 6C 3A 20 50 61 74 63 68 20 69 74 0A 50 61 73  al: Patch it.Pas
00000AC0  73 77 6F 72 64 3A 20 DA 0F 79 6F 75 67 6F 74 74  sword: Ú.yougott
00000AD0  61 70 61 74 63 68 69 74 72 35 00 00 00 DA 07 53  apatchitr5...Ú.S
00000AE0  75 63 63 65 73 73 DA 05 57 72 6F 6E 67 72 29 00  uccessÚ.Wrongr).
00000AF0  00 00                                            ..

which when unmarshalled turns out to

('Made by svenskithesource#2815\nGoal: Patch it\nPassword: ', 'yougottapatchit', 1, 'Success', 'Wrong', 2)

We can change the above tuple to

('Made by svenskithesource#2815\nGoal: Patch it\nPassword: ', '', 1, 'Success', 'Wrongaaaaaaaaaaaaaaa', 2)

"yougottapatchit" is patched to an empty string. To make the final marshalled size of the tuple the same, 15 bytes has been added to the 'Wrong' string. This string won't ever be printed, so that's fine.

The patched marshalled tuple now looks like

00000A80                                      29 06 7A 37              ).z7
00000A90  4D 61 64 65 20 62 79 20 73 76 65 6E 73 6B 69 74  Made by svenskit
00000AA0  68 65 73 6F 75 72 63 65 23 32 38 31 35 0A 47 6F  hesource#2815.Go
00000AB0  61 6C 3A 20 50 61 74 63 68 20 69 74 0A 50 61 73  al: Patch it.Pas
00000AC0  73 77 6F 72 64 3A 20 DA 00 72 35 00 00 00 DA 07  sword: Ú.r5...Ú.
00000AD0  53 75 63 63 65 73 73 DA 14 57 72 6F 6E 67 61 61  SuccessÚ.Wrongaa
00000AE0  61 61 61 61 61 61 61 61 61 61 61 61 61 72 29 00  aaaaaaaaaaaaar).
00000AF0  00 00                                            ..

As a result of the changes the first COMPARE_OP execute as

COMPARE_OP ('tuts4you', '') >=

which is always true.
Any string is always >= an empty string

Now the patched file will accept any password, even empty ones.

main obf v1.2-patched2.pyc

  • Like 1
  • Thanks 1
Link to comment

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...