musava_ribica Posted May 29, 2023 Posted May 29, 2023 View File Python Obfuscated KeygenMe I have provided a zip archive which contains two .py files, as seen in the screenshot. In LicenseChecker.py there is a validation function invoked by main.py where you put the key in the given format. The python code is obfuscated using a obfuscator I'm currently working on. If you solved the challenge, or have any feedback to give, or need hints/help with reversing without spoiling, please let me know. Thanks. You can see original source code and the solution in the 'Authors Code' and 'Authors Solution', if you wish. Good luck! Submitter musava_ribica Submitted 05/29/2023 Category KeygenMe 3
Solution Extreme Coders Posted June 8, 2023 Solution Posted June 8, 2023 This post was recognized by Teddy Rogers! Extreme Coders was awarded the badge 'Great Content' and 1 points. The KeygenMe or more appropriately a CrackMe (since it accepts just a single key) is protected with virtualization based obfuscation. This mini writeup describes a way to obtain the correct key without devirtualization. I - First Steps There are two files - main.py & LicenseChecker.py of which the latter is additionally minified. To improve readability we can run the file through a beautifier like black to get the following code. https://gist.github.com/extremecoders-re/35cb06674676afdcf85bd19d0793d6cc II - Overview The list variable C holds the bytecode for the VM. C=[82,26,95,26,95,26,105, .... snip.... ,571,84,572,84,393,129,3,101,84,103,84,573,76,1,134] The dictionary variable W near the end contains the mappings from the instruction opcode to the corresponding handlers. There are 70 handlers which imply there are the same number of instructions. W = { 10: A0, 179: f, 36: AT, 168: g, # ... snip ... 162: A9, 113: A6, 197: c, 215: AI, } The while loop at the end is the VM fetch-decode-execute loop. while B.a < L(C): try: W[C[B.a]]() except Z as X: A = [X] if not G: raise X P, Ag = G.pop() while F: Ah, Ai, Aj = F and F[-1] or (0, 0, 0) if Ah <= P: break F.pop() B.a = P + Ag B.a += 1 There is a similar loop in one of the handlers AW which implies this must be implementing function calls. Spoiler def AW(): B.a += 3 E = C[B.a - 2] J = C[B.a - 1] D = B.a def H(*P, **Q): H.flags & 1 and A.append(P) H.flags & 2 and A.append(Q) M.append((D, E)) R = B.a B.a = D L = D + E K = I while B.a < L: try: if W[C[B.a]](): K = A.pop() break except Z as N: A.append(N) if not G: raise N J, O = G.pop() while F: S, T, U = F and F[-1] or (0, 0, 0) if S > J: F.pop() else: break if J < D < B.a < D + E < J + O: M.pop() B.a = J + O if not D <= B.a < L: return K B.a += 1 else: M.pop() B.a = R return K H.flags = J B.a = D + E - 1 A.append(H) III - Simplifying the VM The VM supports 70 instructions but not all of them are used. Hence we can remove the unused handlers to simplify the code. This can be done manually in a trial and error way or we can also automate it by logging which handler executes and remove the others. Eventually we are left with 18 handlers which after renaming are as follows. W = { 2: h2, 19: h19, 26: h26, 33: h33, 41: h41, 76: h76, 82: h82, 84: h84, 88: h88, 101: h101, 109: h109, 112: h112, 113: h113, 117: h117, 129: h129, 131: h131, 134: h134, 139: h139, } Full simplified code: https://gist.github.com/extremecoders-re/8962f5faefcd714ce5336461fe670c06 IV - Tracing the VMCALL instruction With 18 handlers left we can now trace the VM. An important thing to note is the obfuscator must have a way to call non-obfuscated external functions such as those from the standard library. If we log the external function it calls, the logic of the crackme would be clear. The instruction with opcode 76 implements the VMCALL instruction. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) We can introduce a logging statement just before the call as shown. below. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments print(A[-1].__name__, F, H) I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) V - Retrieving the correct key Running with the serial and the VMCALL logging in place verify("ABCDE-FGHIJ-KLMNO-PQRST-UVWXY") we get a trace, of which the important parts are shown below. getitem (['ABCDE', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 0) {} getattr ('ABCDE', 'encode') {} encode () {} getattr (<module 'hashlib' from '/usr/lib/python3.10/hashlib.py'>, 'md5') {} openssl_md5 (b'ABCDE',) {} getattr (<md5 _hashlib.HASH object @ 0x7f335f0850f0>, 'digest') {} digest () {} list ((253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52),) {} bytes ([253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52],) {} eq (b'.\xcd\xde9Y\x05\x1d\x91?a\xb1Ey\xea\x13m', b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} not_ (False,) {} It calculates the md5 of the first word -> openssl_md5("ABCDE") which is then compared to b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4". This can be converted to hex representation. >>> print(b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4".hex()) fd65be270a8bedb5f816fb8a56717434 The MD5 hash can be reversed with any online tool such as https://hashes.com/en/decrypt/hash & https://crackstation.net/ The first word is thus CONGR. Re-running with the following key we get another trace. verify("CONGR-FGHIJ-KLMNO-PQRST-UVWXY") getitem (['CONGR', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 1) {} getitem ('FGHIJ', 1) {} eq ('G', 'T') {} Here we see it taking the second word in the key viz FGHIJ and comparing the second character in the word G with T. Thus the correct character at that place is T. Since it stops comparing further letters as soon as a mismatch is found we can only recover the key character by character. However there is a quicker way. We can override the result of the comparison to true such that all the checks are revealed at once. This can be done by a slight modification to the logging logic. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments if A[-1].__name__ == "eq": print(A[-1].__name__, F, H) I = True else: I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) Running once more with the same key as last time we get the full trace as below. eq (29, 29) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4", b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} eq ('G', 'T') {} eq ('O', 'S') {} eq ('Q', 'O') {} eq ('J', 'A') {} eq ('H', 'U') {} eq ('R', 'H') {} eq ('K', 'T') {} eq ('S', 'F') {} eq ('F', 'A') {} eq ('L', 'I') {} eq ('T', 'A') {} eq ('M', 'O') {} eq ('I', 'L') {} eq ('N', 'N') {} eq ('P', 'Q') {} eq ('UYXWV', 'BHZQB') {} From the equality checks we can retrieve the 2nd, 3rd and 4th words in the key. The 1st word has already been retrieved before from the MD5 reversing. CONGR-ATULA-TIONS-QOHFA-UVWXY The fifth word is however checked in a different way. The fifth word in the entered key was UVWXY. However it is checking UYXWV with BHZQB. U -> B Y -> H X -> Z W -> Q V -> B UYXWV is a permutation of the original letters UVWXY. Thus we can simply undo the above mapping in the proper order to get the correct word as shown below. U -> B V -> B W -> Q X -> Z Y -> H The correct last word is BBQZH and thus the complete key: CONGR-ATULA-TIONS-QOHFA-BBQZH 13 5
BlackHat Posted June 8, 2023 Posted June 8, 2023 39 minutes ago, Extreme Coders said: The KeygenMe or more appropriately a CrackMe (since it accepts just a single key) is protected with virtualization based obfuscation. This mini writeup describes a way to obtain the correct key without devirtualization. I - First Steps There are two files - main.py & LicenseChecker.py of which the latter is additionally minified. To improve readability we can run the file through a beautifier like black to get the following code. https://gist.github.com/extremecoders-re/35cb06674676afdcf85bd19d0793d6cc II - Overview The list variable C holds the bytecode for the VM. C=[82,26,95,26,95,26,105, .... snip.... ,571,84,572,84,393,129,3,101,84,103,84,573,76,1,134] The dictionary variable W near the end contains the mappings from the instruction opcode to the corresponding handlers. There are 70 handlers which imply there are the same number of instructions. W = { 10: A0, 179: f, 36: AT, 168: g, # ... snip ... 162: A9, 113: A6, 197: c, 215: AI, } The while loop at the end is the VM fetch-decode-execute loop. while B.a < L(C): try: W[C[B.a]]() except Z as X: A = [X] if not G: raise X P, Ag = G.pop() while F: Ah, Ai, Aj = F and F[-1] or (0, 0, 0) if Ah <= P: break F.pop() B.a = P + Ag B.a += 1 There is a similar loop in one of the handlers AW which implies this must be implementing function calls. Hide contents def AW(): B.a += 3 E = C[B.a - 2] J = C[B.a - 1] D = B.a def H(*P, **Q): H.flags & 1 and A.append(P) H.flags & 2 and A.append(Q) M.append((D, E)) R = B.a B.a = D L = D + E K = I while B.a < L: try: if W[C[B.a]](): K = A.pop() break except Z as N: A.append(N) if not G: raise N J, O = G.pop() while F: S, T, U = F and F[-1] or (0, 0, 0) if S > J: F.pop() else: break if J < D < B.a < D + E < J + O: M.pop() B.a = J + O if not D <= B.a < L: return K B.a += 1 else: M.pop() B.a = R return K H.flags = J B.a = D + E - 1 A.append(H) III - Simplifying the VM The VM supports 70 instructions but not all of them are used. Hence we can remove the unused handlers to simplify the code. This can be done manually in a trial and error way or we can also automate it by logging which handler executes and remove the others. Eventually we are left with 18 handlers which after renaming are as follows. W = { 2: h2, 19: h19, 26: h26, 33: h33, 41: h41, 76: h76, 82: h82, 84: h84, 88: h88, 101: h101, 109: h109, 112: h112, 113: h113, 117: h117, 129: h129, 131: h131, 134: h134, 139: h139, } Full simplified code: https://gist.github.com/extremecoders-re/8962f5faefcd714ce5336461fe670c06 IV - Tracing the VMCALL instruction With 18 handlers left we can now trace the VM. An important thing to note is the obfuscator must have a way to call non-obfuscated external functions such as those from the standard library. If we log the external function it calls, the logic of the crackme would be clear. The instruction with opcode 76 implements the VMCALL instruction. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) We can introduce a logging statement just before the call as shown. below. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments print(A[-1].__name__, F, H) I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) V - Retrieving the correct key Running with the serial and the VMCALL logging in place verify("ABCDE-FGHIJ-KLMNO-PQRST-UVWXY") we get a trace, of which the important parts are shown below. getitem (['ABCDE', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 0) {} getattr ('ABCDE', 'encode') {} encode () {} getattr (<module 'hashlib' from '/usr/lib/python3.10/hashlib.py'>, 'md5') {} openssl_md5 (b'ABCDE',) {} getattr (<md5 _hashlib.HASH object @ 0x7f335f0850f0>, 'digest') {} digest () {} list ((253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52),) {} bytes ([253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52],) {} eq (b'.\xcd\xde9Y\x05\x1d\x91?a\xb1Ey\xea\x13m', b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} not_ (False,) {} It calculates the md5 of the first word -> openssl_md5("ABCDE") which is then compared to b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4". This can be converted to hex representation. >>> print(b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4".hex()) fd65be270a8bedb5f816fb8a56717434 The MD5 hash can be reversed with any online tool such as https://hashes.com/en/decrypt/hash & https://crackstation.net/ The first word is thus CONGR. Re-running with the following key we get another trace. verify("CONGR-FGHIJ-KLMNO-PQRST-UVWXY") getitem (['CONGR', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 1) {} getitem ('FGHIJ', 1) {} eq ('G', 'T') {} Here we see it taking the second word in the key viz FGHIJ and comparing the second character in the word G with T. Thus the correct character at that place is T. Since it stops comparing further letters as soon as a mismatch is found we can only recover the key character by character. However there is a quicker way. We can override the result of the comparison to true such that all the checks are revealed at once. This can be done by a slight modification to the logging logic. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments if A[-1].__name__ == "eq": print(A[-1].__name__, F, H) I = True else: I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) Running once more with the same key as last time we get the full trace as below. eq (29, 29) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4", b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} eq ('G', 'T') {} eq ('O', 'S') {} eq ('Q', 'O') {} eq ('J', 'A') {} eq ('H', 'U') {} eq ('R', 'H') {} eq ('K', 'T') {} eq ('S', 'F') {} eq ('F', 'A') {} eq ('L', 'I') {} eq ('T', 'A') {} eq ('M', 'O') {} eq ('I', 'L') {} eq ('N', 'N') {} eq ('P', 'Q') {} eq ('UYXWV', 'BHZQB') {} From the equality checks we can retrieve the 2nd, 3rd and 4th words in the key. The 1st word has already been retrieved before from the MD5 reversing. CONGR-ATULA-TIONS-QOHFA-UVWXY The fifth word is however checked in a different way. The fifth word in the entered key was UVWXY. However it is checking UYXWV with BHZQB. U -> B Y -> H X -> Z W -> Q V -> B UYXWV is a permutation of the original letters UVWXY. Thus we can simply undo the above mapping in the proper order to get the correct word as shown below. U -> B V -> B W -> Q X -> Z Y -> H The correct last word is BBQZH and thus the complete key: CONGR-ATULA-TIONS-QOHFA-BBQZH This is what I call an actual solution. It explains every step in a proper manner so we can understand each and every action taken by you to solve it. Thank you. 1 1
Washi Posted June 8, 2023 Posted June 8, 2023 @Extreme Coders Excellent write-up. Easy to follow and covers everything. Thanks! 1 1
ra1n Posted June 8, 2023 Posted June 8, 2023 8 hours ago, Extreme Coders said: The KeygenMe or more appropriately a CrackMe (since it accepts just a single key) is protected with virtualization based obfuscation. This mini writeup describes a way to obtain the correct key without devirtualization. I - First Steps There are two files - main.py & LicenseChecker.py of which the latter is additionally minified. To improve readability we can run the file through a beautifier like black to get the following code. https://gist.github.com/extremecoders-re/35cb06674676afdcf85bd19d0793d6cc II - Overview The list variable C holds the bytecode for the VM. C=[82,26,95,26,95,26,105, .... snip.... ,571,84,572,84,393,129,3,101,84,103,84,573,76,1,134] The dictionary variable W near the end contains the mappings from the instruction opcode to the corresponding handlers. There are 70 handlers which imply there are the same number of instructions. W = { 10: A0, 179: f, 36: AT, 168: g, # ... snip ... 162: A9, 113: A6, 197: c, 215: AI, } The while loop at the end is the VM fetch-decode-execute loop. while B.a < L(C): try: W[C[B.a]]() except Z as X: A = [X] if not G: raise X P, Ag = G.pop() while F: Ah, Ai, Aj = F and F[-1] or (0, 0, 0) if Ah <= P: break F.pop() B.a = P + Ag B.a += 1 There is a similar loop in one of the handlers AW which implies this must be implementing function calls. Reveal hidden contents def AW(): B.a += 3 E = C[B.a - 2] J = C[B.a - 1] D = B.a def H(*P, **Q): H.flags & 1 and A.append(P) H.flags & 2 and A.append(Q) M.append((D, E)) R = B.a B.a = D L = D + E K = I while B.a < L: try: if W[C[B.a]](): K = A.pop() break except Z as N: A.append(N) if not G: raise N J, O = G.pop() while F: S, T, U = F and F[-1] or (0, 0, 0) if S > J: F.pop() else: break if J < D < B.a < D + E < J + O: M.pop() B.a = J + O if not D <= B.a < L: return K B.a += 1 else: M.pop() B.a = R return K H.flags = J B.a = D + E - 1 A.append(H) III - Simplifying the VM The VM supports 70 instructions but not all of them are used. Hence we can remove the unused handlers to simplify the code. This can be done manually in a trial and error way or we can also automate it by logging which handler executes and remove the others. Eventually we are left with 18 handlers which after renaming are as follows. W = { 2: h2, 19: h19, 26: h26, 33: h33, 41: h41, 76: h76, 82: h82, 84: h84, 88: h88, 101: h101, 109: h109, 112: h112, 113: h113, 117: h117, 129: h129, 131: h131, 134: h134, 139: h139, } Full simplified code: https://gist.github.com/extremecoders-re/8962f5faefcd714ce5336461fe670c06 IV - Tracing the VMCALL instruction With 18 handlers left we can now trace the VM. An important thing to note is the obfuscator must have a way to call non-obfuscated external functions such as those from the standard library. If we log the external function it calls, the logic of the crackme would be clear. The instruction with opcode 76 implements the VMCALL instruction. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) We can introduce a logging statement just before the call as shown. below. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments print(A[-1].__name__, F, H) I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) V - Retrieving the correct key Running with the serial and the VMCALL logging in place verify("ABCDE-FGHIJ-KLMNO-PQRST-UVWXY") we get a trace, of which the important parts are shown below. getitem (['ABCDE', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 0) {} getattr ('ABCDE', 'encode') {} encode () {} getattr (<module 'hashlib' from '/usr/lib/python3.10/hashlib.py'>, 'md5') {} openssl_md5 (b'ABCDE',) {} getattr (<md5 _hashlib.HASH object @ 0x7f335f0850f0>, 'digest') {} digest () {} list ((253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52),) {} bytes ([253, 101, 190, 39, 10, 139, 237, 181, 248, 22, 251, 138, 86, 113, 116, 52],) {} eq (b'.\xcd\xde9Y\x05\x1d\x91?a\xb1Ey\xea\x13m', b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} not_ (False,) {} It calculates the md5 of the first word -> openssl_md5("ABCDE") which is then compared to b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4". This can be converted to hex representation. >>> print(b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4".hex()) fd65be270a8bedb5f816fb8a56717434 The MD5 hash can be reversed with any online tool such as https://hashes.com/en/decrypt/hash & https://crackstation.net/ The first word is thus CONGR. Re-running with the following key we get another trace. verify("CONGR-FGHIJ-KLMNO-PQRST-UVWXY") getitem (['CONGR', 'FGHIJ', 'KLMNO', 'PQRST', 'UVWXY'], 1) {} getitem ('FGHIJ', 1) {} eq ('G', 'T') {} Here we see it taking the second word in the key viz FGHIJ and comparing the second character in the word G with T. Thus the correct character at that place is T. Since it stops comparing further letters as soon as a mismatch is found we can only recover the key character by character. However there is a quicker way. We can override the result of the comparison to true such that all the checks are revealed at once. This can be done by a slight modification to the logging logic. def h76(): vmctx.pc += 1 E = G.copy() D = bc[vmctx.pc] F = A.pop() if D & 1 else () H = A.pop() if D & 2 else {} # Logging the external function name and arguments if A[-1].__name__ == "eq": print(A[-1].__name__, F, H) I = True else: I = A.pop()(*(F), **H) J = G.copy() E == J and A.append(I) Running once more with the same key as last time we get the full trace as below. eq (29, 29) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (5, 5) {} eq (b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4", b"\xfde\xbe'\n\x8b\xed\xb5\xf8\x16\xfb\x8aVqt4") {} eq ('G', 'T') {} eq ('O', 'S') {} eq ('Q', 'O') {} eq ('J', 'A') {} eq ('H', 'U') {} eq ('R', 'H') {} eq ('K', 'T') {} eq ('S', 'F') {} eq ('F', 'A') {} eq ('L', 'I') {} eq ('T', 'A') {} eq ('M', 'O') {} eq ('I', 'L') {} eq ('N', 'N') {} eq ('P', 'Q') {} eq ('UYXWV', 'BHZQB') {} From the equality checks we can retrieve the 2nd, 3rd and 4th words in the key. The 1st word has already been retrieved before from the MD5 reversing. CONGR-ATULA-TIONS-QOHFA-UVWXY The fifth word is however checked in a different way. The fifth word in the entered key was UVWXY. However it is checking UYXWV with BHZQB. U -> B Y -> H X -> Z W -> Q V -> B UYXWV is a permutation of the original letters UVWXY. Thus we can simply undo the above mapping in the proper order to get the correct word as shown below. U -> B V -> B W -> Q X -> Z Y -> H The correct last word is BBQZH and thus the complete key: CONGR-ATULA-TIONS-QOHFA-BBQZH Amazing write-up, thank you!! 1 1
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now