Reversing a PyArmor-protected Windows executable
Introduction
Dans cet article, je vais vous expliquer comment j’ai réussi à obtenir la version premium d’un bot Dokkan Battle que j’ai trouvé sur internet et sans payer.
Le programme est un executable windows protégé par PyArmor (v8.5.10).
Avertissement
Les informations et techniques présentées dans cet article sont fournies à des fins éducatives uniquement. Je décline toute responsabilité quant à l’utilisation de ces informations à des fins autres que celles de l’apprentissage et de la compréhension. Veuillez respecter toutes les lois et règlements applicables.
Etapes d’exploitation
Analyse du programme
On peut voir à l’aide de Detect It Easy que le programme est packé avec PyInstaller (globalement un outil pour créer des executables autonomes à partir de fichiers .py).
Nous pouvons donc utiliser pyinstxtrator pour extraire tous les fichiers de l’archive pyinstaller.
ATTENTION : IL FAUT UTILISER LA MÊME VERSION DE PYTHON QUE CELLE DU BINAIRE.
[+] Processing Dokkan-FarmBot.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.10
[+] Length of package: 174643443 bytes
[+] Found 8081 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pyqt5webengine.pyc
[+] Possible entry point: pyi_rth_pkgres.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: pyi_rth__tkinter.pyc
[+] Possible entry point: pyi_rth_pyqt5.pyc
[+] Possible entry point: main.pyc
[+] Found 2987 files in PYZ archive
[+] Successfully extracted pyinstaller archive: Dokkan-FarmBot.exe
L’extraction a réussi, on peut donc aller dans le dossier Dokkan-FarmBot.exe_extracted pour voir ce qu’il contient.
On peut voir que pyinstxtrator a réussi à reconstruire le fichier main.py qui est l’entry point de notre programme.
Ce fichier est protégé à l’aide de PyArmor :
from pyarmor_runtime_004676 import __pyarmor__
__pyarmor__(__name__, __file__, b'PY004676\x00\x03\n\x00o\r\r\n\x80\x00\x01\x00\x0.......
On peut essayer de le lancer directement.
> python main.py
Traceback (most recent call last):
File "C:\Users\<USER>\Desktop\DokkanReverse\Dokkan-FarmBot.exe_extracted\main.py", line 1, in <module>
from pyarmor_runtime_004676 import __pyarmor__
ImportError: cannot import name '__pyarmor__' from 'pyarmor_runtime_004676' (unknown location)
On ne peut pas lancer le programme directement, il va falloir modifier un peu la structure du projet extrait pour pouvoir le lancer.
Reconstruction du projet
Le dossier pyarmor_runtime_004676 existe bien mais on dirait que notre programme main n’arrive pas à importer son contenu, il doit manquer des fichiers.
Si on regarde dans le dossier PYZ-00.pyz_extracted puis dans le dossier pyarmor_runtime_004676, on voit qu’il y a un fichier __init__.pyc.
On peut glisser ce fichier dans le dossier pyarmor_runtime_004676 qui ne le contient pas et on relance le programme.
> python main.py
C:/Users/<USER>/AppData/Local/Programs/Python/Python310/tcl/tcl8.6/init.tcl: version conflict for package "Tcl": have 8.6.12, need exactly 8.6.10
version conflict for package "Tcl": have 8.6.12, need exactly 8.6.10
while executing
"package require -exact Tcl 8.6.10"
(file "C:/Users/<USER>/AppData/Local/Programs/Python/Python310/tcl/tcl8.6/init.tcl" line 19)
invoked from within
"source C:/Users/<USER>/AppData/Local/Programs/Python/Python310/tcl/tcl8.6/init.tcl"
("uplevel" body line 1)
invoked from within
"uplevel #0 [list source $tclfile]"
L’erreur précédente a disparu mais maintenant nous avons un problème de compatibilité de version de Tcl (utilisé par tkinter).
Il suffit d’aller dans le fichier C:/Users/<USER>/AppData/Local/Programs/Python/Python310/tcl/tcl8.6/init.tcl et de commenter cette ligne (avec un # avant) :
package require -exact Tcl 8.6.10
On peut réessayer de lancer le programme.
> python main.py
Aucune erreur et une fenêtre s’ouvre pour nous demander la version à utiliser.
On clique sur Global :
Program crashed : [Errno 2] No such file or directory: 'config_gb.json'
Il ne faut pas oublier de mettre les fichiers qui étaient à côté du .exe dans le dossier qui contient maintenant le main.py
Une fois les fichiers déplacés, le programme fonction correctement et nous pouvons commencer l’exploitation.
Trouver une méthode d’exploitation
Pour trouver une méthode d’exploitation, je vais analyser le comportement du programme pour essayer de savoir comment il fonctionne. Une fois le programme lancé, on peut voir cette interface :
On voit que le programme a détecté que je suis sur la version gratuite et ma première idée fut que le programme fait une requête HTTP pour savoir si je suis premium ou non.
Pour vérifier on peut utiliser une méthode qui se nomme Library Hijacking.
POINT IMPORTANT : Cette méthode ne marche que dans certains conditions et le fait d’avoir désempaqueté le .exe permet d’utiliser les librairies python installées sur mon ordinateur, plutôt que les librairies compilées contenues dans le .exe.
Je me suis donc penché vers une des librairies les plus utilisées pour faire des requêtes HTTP en python : requests.
On peut donc vérifier si cette méthode va marcher en modifiant la librairie requests pour afficher le contenu des requêtes HTTP envoyées.
Pour ce faire je me rends ici : C:\Users\<USER>\AppData\Local\Programs\Python\Python310\Lib\site-packages\requests et je cherche la méthode utilisée pour envoyer des requêtes HTTP.
Dans le fichier sessions.py on peut apercevoir cette méthode :
def request(
self,
method,
url,
params=None,
data=None,
headers=None,
cookies=None,
files=None,
auth=None,
timeout=None,
allow_redirects=True,
proxies=None,
hooks=None,
stream=None,
verify=None,
cert=None,
json=None,
):
Pour vérifier si c’est la bonne méthode, je vais ajouter ce code avant le retour de la fonction :
print("[REQUEST]")
print(url)
print(req.headers)
print(req.params)
print(req.json)
Et je vais relancer le programme :
> python main.py
[REQUEST]
https://[REDACTED].herokuapp.com/device_token/jp
{'Authorization': 'Bearer [REDACTED]'}
{}
{'data': 'fab039758e93aa52c06d29c5211b56e291f41328d4106222cd8c6f12a24ef248205e39cd1bdd9d73b39334b9c234c764f28beb10368bb5ef3bfc6557f8034fe682bd4ced1576573e1205b4aeeb81de5f1c8000a5d93bcf52bda0257768c3895a73ff105e26e38c0c5f965a2a674db468ad6ff8f9579ec1865d569bcdeb9e8b5b07ad19aa426701effcf7c0a595b624926ae6689418b7988ed3fd2fb95bc16946f9c0c852b7b5e755327a1f41fffb', 'signature': 'a253161be4e859dd7bc87ecb19aa68dd637ee46aa2b049b20d7a1ea3e281d9c6'}
[REQUEST]
https://[REDACTED].herokuapp.com/is_member_check
{'Authorization': 'Bearer [REDACTED]'}
{}
{'data': 'fab039758e93aa52c06d29c5211b56e291f41328d4106222cd8c6f12a24ef248205e39cd1bdd9d73b39334b9c234c764f28beb10368bb5ef3bfc6557f8034fe682bd4ced1576573e1205b4aeeb81de5f1c8000a5d93bcf52bda0257768c3895a73ff105e26e38c0c5f965a2a674db468ad6ff8f9579ec1865d569bcdeb9e8b5b07ad19aa426701effcf7c0a595b624926ae6689418b7988ed3fd2fb95bc16946f9c0c852b7b5e755327a1f41fe20', 'signature': '98bae068a05dad8796ee4567af4b53e12444c364d1197ad72997f1493c3535e5'}
[REQUEST]
https://[REDACTED].herokuapp.com/is_premium_member
{'Authorization': 'Bearer [REDACTED]'}
{}
{'data': 'fab039758e93aa52c06d29c5211b56e291f41328d4106222cd8c6f12a24ef248205e39cd1bdd9d73b39334b9c234c764f28beb10368bb5ef3bfc6557f8034fe682bd4ced1576573e1205b4aeeb81de5f1c8000a5d93bcf52bda0257768c3895a73ff105e26e38c0c5f965a2a674db468ad6ff8f9579ec1865d569bcdeb9e8b5b07ad19aa426701effcf7c0a595b624926ae6689418b7988ed3fd2fb95bc16946f9c0c852b7b5e755327a1f41fe20', 'signature': '98bae068a05dad8796ee4567af4b53e12444c364d1197ad72997f1493c3535e5'}
On peut voir que la méthode Library Hijacking fonctionne ! Et on va pouvoir l’utiliser.
Exploitation Library Hijacking
On peut voir qu’il y a une requête effectuée sur l’endpoint is_member_check
puis sur l’endpoint is_premium_member
mais les données envoyées sont chiffrées (surement avec l’algorithme RSA) et donc il théoriquement impossible de renvoyer des données disant que notre compte est premium car la clé privée pour chiffrer ces données doit se trouver sur le serveur).
On peut alors essayer d’inspecter la stack du programme au moment de la requête vers l’endpoint is_member_check
pour savoir ce qu’il se passe.
On ajoute donc ce code python dans la méthode send de la librairie requests :
if req.url == "https://[REDACRED]/is_member_check":
import inspect # Utilisation du module inspect pour analyser la stack
for frame in inspect.stack(): # Boucler sur la stack
print(frame.function) # Afficher la fonction de la frame
On relance le programme :
> python main.py
[REQUEST]
https://[REDACTED]/is_member_check
{'Authorization': 'Bearer [REDACTED]'}
{}
{'data': 'fab039758e93aa52c06d29c5211b56e291f41328d4106222cd8c6f12a24ef248205e39cd1bdd9d73b39334b9c234c764f28beb10368bb5ef3bfc6557f8034fe682bd4ced1576573e1205b4aeeb81de5f1c8000a5d93bcf52bda0257768c3895a73ff105e26e38c0c5f965a2a674db468ad6ff8f9579ec1865d569bcdeb9e8b5b07ad19aa426701effcf7c0a595b624926ae6689418b7988ed3fd2fb95bc16946f9c0c852b7b5e755327889f25f87', 'signature': 'c82234f214af30aef855f22a97894bfc1e6d9e8ff4ed530dc896790f50a51134'}
request
post
is_guild_member
isOnServer
start
<module>
<module>
On peut voir que dans une des frame se trouve la fonction isOnServer
et c’est surement celle qui initie la requête vers l’endpoint is_member_check
. On peut donc modifier le code python pour l’analyser en profondeur :
if req.url == "https://[REDACTED]/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals # local namespace seen by this frame
print(current_variables)
On relance le programme :
> python main.py
{'self': <startup._Startup object at 0x0000021B41191DE0>, '_var_var_17': False}
On peut voir grâce à la f_local self que la méthode isOnServer
doit se trouver dans une classe.
On peut donc récupérer une instance de cette classe et l’analyser en modifiant le code python comme ceci :
if req.url == "https://[REDACTED]/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals
self_instance = current_variables["self"] # Récupération d'une instance de self
print(dir(self_instance)) # Affichage de cette instance
On relance le programme :
> python main.py
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'account_link_ctrl', 'account_manager', 'base_url', 'check_bot_status', 'check_bot_update', 'check_bot_version', 'check_version_status', 'client_config', 'cmd_excutor', 'cryption_client', 'execute_command', 'execute_custom_commands', 'execute_obfuscated_function', 'firebase', 'firebase_login', 'game_client', 'get_obfuscated_function', 'headers', 'init_bot_checks', 'isOnServer', 'isPremium', 'isServerBooster', 'is_valid_user', 'local_version', 'platform', 'pypresence', 'pypresence_state', 'query_client', 'select_language', 'select_platform', 'select_version', 'start', 'version', 'xor_encrypt_decrypt']
On peut voir que cette classe contient différentes fonctions notamment la fonction isPremium
:D
Comme l’instance qu’on a récupéré agit comme un pointeur, on peut essayer de modifier la fonction isPremium
avant qu’elle soit appelée pour qu’elle retourne True.
On ajoute ce code au début du fichier sessions.py :
def fake_ispremium(cls, *args, **kwargs):
print("FONCTION IsPremium appelée par le programme")
return True
Et dans notre méthode send :
if req.url == "https://[REDACTED]/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals
self_instance = current_variables["self"]
self_instance.__class__.isPremium = classmethod(fake_ispremium) # On remplace la méthode originale par la notre
On relance le programme :
> python main.py
FONCTION IsPremium appelée par le programme
-----------------------------------------------------------------
SMILE free version
-------------------------USER SETTINGS-------------------------
+ - Preferences
-------------------------BOT TUTORIAL-------------------------
0 - Bot guide
-------------------------SIGN IN OPTIONS-------------------------
1 - Create account
2 - Facebook login
3 - Google login (ios only!)
4 - Save login
5 - Identifier login
-------------------------BOT SETTINGS-------------------------
6 - Change version and platform
-------------------------MEMBERSHIPS-------------------------
7 - Premium subscription
-------------------------COMMANDS MAPPING-------------------------
8 - Commands set file on all accounts
------------------------------------------------------------
Option:
On a réussi à remplacer la méthode isPremium
de la classe mais on voit que nous ne sommes pas premium pour autant sur le bot.
J’ai donc pensé que la méthode isPremium
ne retournait pas une valeur mais définissait plutôt un attribut d’instance comme par exemple self.ispremium = result
qui était utilisé ailleurs dans le programme.
Pour vérifier, nous allons devoir récupérer les attributs d’instance de la classe en modifiant notre code comme ceci :
if req.url == "https://dokkan-smile-api-cdf807e32be6.herokuapp.com/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals
self_instance = current_variables["self"]
instance_vars = self_instance.__dict__
print("Attributs d'instance :")
for attr, value in instance_vars.items():
print(f"{attr}: {value}")
self_instance.__class__.isPremium = classmethod(fake_ispremium)
On relance le programme :
> python main.py
Attributs d'instance :
version: gb
firebase: <Auth.smileClient.AuthSession object at 0x0000029D4B195780>
client_config: <Services.settings.Settings object at 0x0000029D4B1956F0>
base_url: [REDACTED]
headers: {'Cache-Control': 'no-cache', 'Authorization': '[REDACTED]'}
local_version: 2.1.5-093ae3875eb8wef2ac1be0888ca85cdb9
pypresence_state: <Services.pypre.DiscordRichPresence object at 0x0000029D4B196320>
query_client: <Services.queryClient.QueryClient object at 0x0000029D4B1975B0>
cryption_client: <Cryption.cryption.EncryptionUtils object at 0x0000029D4B197C40>
game_client: <Services.dokkan.Dokkan object at 0x0000029D4B197070>
account_link_ctrl: <Services.account_linking.AccountLinking object at 0x0000029D4B197DC0>
platform: ios
On ne voit pas d’attribut lié au premium mais on peut voir l’attribut client_config
qui semble intéressant et qu’on peut surement inspecter comme ceci :
if req.url == "https://[REDACTED]/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals
self_instance = current_variables["self"]
instance_vars = self_instance.__dict__
client_config = self_instance.client_config # Recupération de client_config
for attr, value in client_config.__dict__.items():
print(f"{attr}: {value}") # Affichage des éléments de client_config
self_instance.__class__.isPremium = classmethod(fake_ispremium)
On relance le programme :
> python main.py
enable_clear_interval_jp: False
version: gb
host: [REDACTED]
game_version: 5.22.1-b029e84a02a88dabba5c66a210d14f9f69c27d69d23d2b13dcdf50b9f660ff6c
skip_animations: False
bot_ver: 2.1.5
botSecurityHash: [REDACTED]
language: en
auto_team: True
refill_stamina_stones: True
sell_sr_baba: True
current_account: None
facebook_id: None
facebook_token: None
user_agent: BNGI0221/23052616 CFNetwork/1209 Darwin/20.2.0
username:
platform: ios
AdId: None
UniqueId: None
identifier: None
access_token: None
secret: None
deck: 1
discord: False
discord_id: None
premium: False
is_server_booster: False
imgur_client_id: b17bdd86298b02f
imgur_client_secret: [REDACTED]
webhook_url: https://discord.com/api/webhooks/[REDACTED]
cryptionClient: <Cryption.cryption.EncryptionUtils object at 0x000001FC3F5C7C10>
queryClient: <Services.queryClient.QueryClient object at 0x000001FC3F5C7580>
On peut voir que client_config
contient un attribut premium
qui est défini à False
.
On peut essayer de le modifier à True
pour voir ce qu’il se passe (et on laisse le remplacement de la méthode IsPremium
pour ne pas qu’elle le remette à False
) :
if req.url == "https://[REDACTED]/is_member_check":
import inspect
for frame in inspect.stack():
if frame.function == "isOnServer":
current_variables = frame.frame.f_locals
self_instance = current_variables["self"]
instance_vars = self_instance.__dict__
client_config = self_instance.client_config
client_config.premium = True # On passe la variable premium à vrai
self_instance.__class__.isPremium = classmethod(fake_ispremium) # Remplacement de la méthode isPremium pour qu'elle ne fassee rien
On relance le programme :
Et voila ! Nous avons pu exploiter cette vulnérabilité pour avoir le premium sur ce bot sans payer.
Conclusion
PyArmor n’est pas quelque chose de très complexe à bypass la plupart du temps, il suffit de comprendre la logique de notre programme et trouver une vulnérabilité à exploiter.