2025-02-27 14:01:14 +08:00
import argparse
from Crypto . Cipher import AES
import logging
import os
import subprocess
2025-03-04 21:22:34 +08:00
from typing import Dict , List , Tuple
2025-02-27 14:01:14 +08:00
2025-03-25 23:47:21 +08:00
from detect import detect_process
2025-02-27 14:01:14 +08:00
from runtime import RuntimeInfo
SUBPROCESS_TIMEOUT = 30
def general_aes_ctr_decrypt ( data : bytes , key : bytes , nonce : bytes ) - > bytes :
cipher = AES . new ( key , AES . MODE_CTR , nonce = nonce , initial_value = 2 )
return cipher . decrypt ( data )
2025-03-04 21:22:34 +08:00
def decrypt_process ( runtimes : Dict [ str , RuntimeInfo ] , sequences : List [ Tuple [ str , bytes ] ] , args ) :
2025-02-27 14:01:14 +08:00
logger = logging . getLogger ( ' shot ' )
2025-03-02 23:48:24 +08:00
output_dir : str = args . output_dir or args . directory
2025-02-27 14:01:14 +08:00
for path , data in sequences :
try :
serial_number = data [ 2 : 8 ] . decode ( ' utf-8 ' )
runtime = runtimes [ serial_number ]
logger . info ( f ' Decrypting: { serial_number } ( { path } ) ' )
dest_path = os . path . join ( output_dir , path ) if output_dir else path
dest_dir = os . path . dirname ( dest_path )
if not os . path . exists ( dest_dir ) :
os . makedirs ( dest_dir )
2025-03-02 23:48:24 +08:00
if args . export_raw_data :
with open ( dest_path + ' .1shot.raw ' , ' wb ' ) as f :
f . write ( data )
2025-02-27 14:01:14 +08:00
cipher_text_offset = int . from_bytes ( data [ 28 : 32 ] , ' little ' )
cipher_text_length = int . from_bytes ( data [ 32 : 36 ] , ' little ' )
nonce = data [ 36 : 40 ] + data [ 44 : 52 ]
with open ( dest_path + ' .1shot.seq ' , ' wb ' ) as f :
f . write ( b ' \xa1 ' + runtime . runtime_aes_key )
f . write ( b ' \xa2 ' + runtime . mix_str_aes_nonce ( ) )
2025-02-27 16:10:30 +08:00
f . write ( b ' \xf0 \xff ' )
2025-02-27 14:01:14 +08:00
f . write ( data [ : cipher_text_offset ] )
f . write ( general_aes_ctr_decrypt (
data [ cipher_text_offset : cipher_text_offset + cipher_text_length ] , runtime . runtime_aes_key , nonce ) )
f . write ( data [ cipher_text_offset + cipher_text_length : ] )
exe_name = ' pyarmor-1shot.exe ' if os . name == ' nt ' else ' pyarmor-1shot '
exe_path = os . path . join (
os . path . dirname ( os . path . abspath ( __file__ ) ) , exe_name )
# TODO: multi process
sp = subprocess . run (
[
exe_path ,
dest_path + ' .1shot.seq ' ,
] ,
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
timeout = SUBPROCESS_TIMEOUT ,
)
2025-03-07 00:10:45 +08:00
stdout = sp . stdout . decode ( ' latin-1 ' ) . splitlines ( )
stderr = sp . stderr . decode ( ' latin-1 ' ) . splitlines ( )
2025-02-27 14:01:14 +08:00
for line in stdout :
2025-03-05 18:08:18 +08:00
logger . warning ( f ' PYCDC: { line } ( { path } ) ' )
2025-02-27 14:01:14 +08:00
for line in stderr :
2025-03-05 18:08:18 +08:00
if line . startswith ( (
' Warning: Stack history is empty ' ,
2025-03-05 20:07:46 +08:00
' Warning: Stack history is not empty ' ,
' Warning: block stack is not empty ' ,
2025-03-05 18:08:18 +08:00
) ) :
if args . show_warn_stack or args . show_all :
logger . warning ( f ' PYCDC: { line } ( { path } ) ' )
elif line . startswith ( ' Unsupported opcode: ' ) :
if args . show_err_opcode or args . show_all :
logger . error ( f ' PYCDC: { line } ( { path } ) ' )
2025-03-05 20:07:46 +08:00
elif line . startswith ( ' Something TERRIBLE happened ' ) :
if args . show_all :
logger . error ( f ' PYCDC: { line } ( { path } ) ' )
2025-03-05 18:08:18 +08:00
else :
logger . error ( f ' PYCDC: { line } ( { path } ) ' )
if sp . returncode != 0 :
logger . warning ( f ' PYCDC returned { sp . returncode } ( { path } ) ' )
continue
2025-02-27 14:01:14 +08:00
except Exception as e :
logger . error ( f ' Decrypt failed: { e } ( { path } ) ' )
continue
def parse_args ( ) :
parser = argparse . ArgumentParser (
description = ' Pyarmor Static Unpack 1 Shot Entry ' )
parser . add_argument (
' directory ' ,
help = ' the " root " directory of obfuscated scripts ' ,
type = str ,
)
parser . add_argument (
' -r ' ,
' --runtime ' ,
help = ' path to pyarmor_runtime[.pyd|.so|.dylib] ' ,
type = str , # argparse.FileType('rb'),
)
parser . add_argument (
' -o ' ,
' --output-dir ' ,
help = ' save output files in another directory instead of in-place, with folder structure remain unchanged ' ,
type = str ,
)
2025-03-02 23:48:24 +08:00
parser . add_argument (
' --export-raw-data ' ,
help = ' save data found in source files as-is ' ,
action = ' store_true ' ,
)
2025-03-05 18:08:18 +08:00
parser . add_argument (
' --show-all ' ,
help = ' show all pycdc errors and warnings ' ,
action = ' store_true ' ,
)
2025-03-02 23:48:24 +08:00
parser . add_argument (
' --show-err-opcode ' ,
2025-03-05 18:08:18 +08:00
help = ' show pycdc unsupported opcode errors ' ,
action = ' store_true ' ,
)
parser . add_argument (
' --show-warn-stack ' ,
help = ' show pycdc stack related warnings ' ,
2025-03-02 23:48:24 +08:00
action = ' store_true ' ,
)
2025-02-27 14:01:14 +08:00
return parser . parse_args ( )
def main ( ) :
args = parse_args ( )
logging . basicConfig (
level = logging . INFO ,
format = ' %(levelname)-8s %(asctime)-28s %(message)s ' ,
)
logger = logging . getLogger ( ' shot ' )
2025-02-28 16:56:15 +08:00
print ( r '''
2025-02-28 00:39:00 +08:00
____ ____
( __ ) ( __ )
| | ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ | |
| | ____ _ ___ _ _ | |
| | | _ \ _ _ __ _ _ __ _ _ __ ___ _ _ / / __ | | | _ ___ | | _ | |
| | | | _ ) | | | | / _ ` | ' __| ' ` \ / _ \| ' _| | \ __ \ | ' \ / _ \| __ | | |
| | | __ / | | | | ( _ | | | | | | | | | ( _ ) | | | | __ ) | | | | ( _ ) | | _ | |
| | | _ | \_ , | \__ , _ | _ | | _ | | _ | | _ | \___ / | _ | | _ | ___ / | _ | | _ | \___ / \__ | | |
| | | __ / | |
| __ | ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ | __ |
( ____ ) ( ____ )
2025-03-05 10:41:09 +08:00
For technology exchange only . Use at your own risk .
GitHub : https : / / github . com / Lil - House / Pyarmor - Static - Unpack - 1 shot
2025-02-28 00:39:00 +08:00
''' )
2025-02-27 14:01:14 +08:00
if args . runtime :
specified_runtime = RuntimeInfo ( args . runtime )
2025-03-25 23:47:21 +08:00
print ( specified_runtime )
2025-02-27 14:01:14 +08:00
runtimes = { specified_runtime . serial_number : specified_runtime }
else :
specified_runtime = None
runtimes = { }
2025-03-04 21:22:34 +08:00
sequences : List [ Tuple [ str , bytes ] ] = [ ]
2025-02-27 14:01:14 +08:00
if args . output_dir and not os . path . exists ( args . output_dir ) :
os . makedirs ( args . output_dir )
2025-03-25 23:47:21 +08:00
if os . path . isfile ( args . directory ) :
if specified_runtime is None :
logger . error ( ' Please specify `pyarmor_runtime` file by `-r` if input is a file ' )
return
logger . info ( ' Single file mode ' )
result = detect_process ( args . directory , args . directory )
if result is None :
logger . error ( ' No armored data found ' )
return
sequences . extend ( result )
decrypt_process ( runtimes , sequences , args )
return # single file mode ends here
2025-02-27 14:01:14 +08:00
dir_path : str
2025-03-04 21:22:34 +08:00
dirs : List [ str ]
files : List [ str ]
2025-02-27 14:01:14 +08:00
for dir_path , dirs , files in os . walk ( args . directory , followlinks = False ) :
2025-03-25 23:47:21 +08:00
if ' .no1shot ' in files :
logger . info ( f ' Skipping { dir_path } because of `.no1shot` ' )
dirs . clear ( )
files . clear ( )
continue
2025-02-27 14:01:14 +08:00
for d in [ ' __pycache__ ' , ' site-packages ' ] :
if d in dirs :
dirs . remove ( d )
for file_name in files :
if ' .1shot. ' in file_name :
continue
2025-03-25 23:47:21 +08:00
2025-02-27 14:01:14 +08:00
file_path = os . path . join ( dir_path , file_name )
relative_path = os . path . relpath ( file_path , args . directory )
2025-03-25 23:47:21 +08:00
if file_name . endswith ( ' .pyz ' ) :
with open ( file_path , ' rb ' ) as f :
head = f . read ( 16 * 1024 * 1024 )
if b ' PY00 ' in head \
and ( not os . path . exists ( file_path + ' _extracted ' )
or len ( os . listdir ( file_path + ' _extracted ' ) ) == 0 ) :
logger . error (
f ' A PYZ file containing armored data is detected, but the PYZ file has not been extracted by other tools. This error is not a problem with this tool. If the folder is extracted by Pyinstxtractor, please read the output information of Pyinstxtractor carefully. ( { relative_path } ) ' )
continue
2025-02-27 14:01:14 +08:00
# is pyarmor_runtime?
2025-03-25 23:47:21 +08:00
if specified_runtime is None \
2025-02-27 14:01:14 +08:00
and file_name . startswith ( ' pyarmor_runtime ' ) \
2025-03-02 23:48:24 +08:00
and file_name . endswith ( ( ' .pyd ' , ' .so ' , ' .dylib ' ) ) :
2025-02-27 14:01:14 +08:00
try :
new_runtime = RuntimeInfo ( file_path )
runtimes [ new_runtime . serial_number ] = new_runtime
logger . info (
f ' Found new runtime: { new_runtime . serial_number } ( { file_path } ) ' )
print ( new_runtime )
2025-03-25 23:47:21 +08:00
continue
2025-02-27 14:01:14 +08:00
except :
pass
2025-03-25 23:47:21 +08:00
result = detect_process ( file_path , relative_path )
if result is not None :
sequences . extend ( result )
2025-02-27 14:01:14 +08:00
2025-03-05 10:41:09 +08:00
if not runtimes :
logger . error ( ' No runtime found ' )
return
if not sequences :
logger . error ( ' No armored data found ' )
return
2025-03-02 23:48:24 +08:00
decrypt_process ( runtimes , sequences , args )
2025-02-27 14:01:14 +08:00
if __name__ == ' __main__ ' :
main ( )