How PyArmor Does It

Look at what happened afterfoo.py is obfuscated by PyArmor. Here are thefiles list in the output pathdist:

foo.pypytransform/__init__.py_pytransform.so,or_pytransform.dllinWindows,_pytransform.dylibinMacOSpytransform.keylicense.lic

dist/foo.py is obfuscated script, the content is:

frompytransformimportpyarmor_runtimepyarmor_runtime()__pyarmor__(__name__,__file__,b'\x06\x0f...')

There is an extra folderpytransform calledRuntime Package, which arethe only required to run or import obfuscated scripts. So long as this packageis in any Python Path, the obfuscated scriptdist/foo.py can be used as normalPython script. That is to say:

The original python scripts can be replaced with obfuscated scripts seamlessly.

How to Obfuscate Python Scripts

How to obfuscate python scripts by PyArmor?

First compile python script to code object:

char*filename="foo.py";char*source=read_file(filename);PyCodeObject*co=Py_CompileString(source,"<frozen foo>",Py_file_input);

Then change code object as the following way

  • Wrap byte codeco_code within atry...finally block:

    wrapheader:LOAD_GLOBALSN(__armor_enter__)N=lengthofco_constsCALL_FUNCTION0POP_TOPSETUP_FINALLYX(jumptowrapfooter)X=sizeoforiginalbytecodechangedoriginalbytecode:IncreaseopargofeachabsolutejumpinstructionbythesizeofwrapheaderObfuscateoriginalbytecode...wrapfooter:LOAD_GLOBALSN+1(__armor_exit__)CALL_FUNCTION0POP_TOPEND_FINALLY
  • Append function names__armor_enter__,__armor_exit__ toco_consts

  • Increaseco_stacksize by 2

  • Set CO_OBFUSCAED (0x80000000) flag inco_flags

  • Change all code objects in theco_consts recursively

Next serializing reformed code object and obfuscate it to protect constants andliteral strings:

char*string_code=marshal.dumps(co);char*obfuscated_code=obfuscate_algorithm(string_code);

Finally generate obfuscated script:

sprintf(buf,"__pyarmor__(__name__, __file__, b'%s')",obfuscated_code);save_file("dist/foo.py",buf);

The obfuscated script is a normal Python script, it looks like this:

__pyarmor__(__name__,__file__,b'\x01\x0a...')

How to Deal With Plugins

In PyArmor, the plugin is used to inject python code into the obfuscted scriptbefore the script is obfuscated, thus the plugin code could be executed when theobfuscated script is running. For example, use a plugin to check internet time:

pyarmorobfuscate--plugincheck_ntp_timefoo.py

Why not insert the plugin code into the script directly? Because most of themmust be called in the obufscated scripts. For example, get the licenseinformation of the obfuscated scripts.

Each plugin is a normal Python script, PyArmor searches it by this way:

  • If the plugin has absolute path, then find the corresponding.py file exactly.
  • If it has relative path, search the.py file in:
    • The current path
    • $HOME/.pyarmor/plugins
    • {pyarmor_folder}/plugins
  • Raise exception if not found

When there is plugin specified as obfuscating the script, each comment line willbe scanned to find any plugin marker.

There are 3 types of plugin marker:

  • Plugin Definition Marker
  • Plugin Inline Marker
  • Plugin Call Marker

ThePlugin Definition Marker looks like this:

# {PyArmor Plugins}

Generally there is only one in a script, all the plugins will be injectedhere. It must be one leading comment line, no indentation. If there is no plugindefinition marker, none of plugins will be injected.

The others are mainly used to call the function defined in the pluginscripts. There are 3 forms, any comment line with this prefix will be as aplugin marker:

# PyArmor Plugin:# pyarmor_# @pyarmor_

They could appear many times, in any indentation, generally should be behindplugin definition marker.

The first form calledPlugin Inline Marker, PyArmor just removes this patternand one following whitespace exactly, and leave the rest part as it is. Forexample, these are inline markers in the scriptfoo.py:

# PyArmor Plugin: check_ntp_time()# PyArmor Plugin: print('This is plugin code')# PyArmor Plugin: if sys.flags.debug:# PyArmor Plugin:     check_something():

In thedist/foo.py, they’ll be replaced as:

check_ntp_time()print('This is plugin code')ifsys.flags.debug:check_something()

So long as there is any plugin specified in the command line, these replacementswill be taken place. If there is no external plugin script, use special pluginnameon in the command line. For example:

pyarmorobfuscate--pluginonfoo.py

The second form calledPlugin Call Marker, it’s only used to call functiondeinfed in the plugin script. Besides, if this function name is not specified asplugin name, PyArmor doesn’t touch this marker. For example, obufscate thescript by this command:

pyarmorobfuscate--plugincheck_ntp_timefoo.py

In thefoo.py, only the first marker will be handled, the second marker willbe kept as it is, because there is no plugin name specified in the command lineas the function namecheck_multi_mac:

# pyarmor_check_ntp_time()# pyarmor_check_multi_mac()==>check_ntp_time()# pyarmor_check_multi_mac()

The last form#@pyarmor_ is almost same as the second, but the commentprefix will be replaced with@, it’s mainly used to inject a decorator. Forexample:

# @pyarmor_assert_obfuscated(foo.connect)deflogin(user,name):foo.connect(user,name)==>@assert_obfuscated(foo.connect)deflogin(user,name):foo.connect(user,name)

If the plugin name have a leading@, it will be injected into the scriptonly when it’s used in the script, otherwise it’s ignored. For example:

pyarmorobfuscate--plugin@check_ntp_timefoo.py

The scriptfoo.py must call plugin functioncheck_ntp_time by one ofPlugin Call Marker. For example:

# pyarmor_check_ntp_time()

ThePlugin Inline Marker doesn’t work. For example:

# PyArmor Plugin: check_ntp_time()

Even this marker will be replaced withcheck_ntp_time(), but the pluginscript will not be injected into the obfuscated script. When it runs, it willcomplain of no functioncheck_ntp_name found.

Note

If there is no option--plugin in the command line, pyarmor DOES NOTsearch any plugin marker in the comment. If there is no external pluginscript, use special nameon like this:

pyarmorobfuscate--pluginonfoo.py

Special Handling of Entry Script

There are 2 extra changes for entry script:

  • Before obfuscating, insert protection code to entry script.
  • After obfuscated, insert bootstrap code to obfuscated script.

Before obfuscating entry scipt, PyArmor will search the content line by line. Ifthere is line like this:

# {PyArmor Protection Code}

PyArmor will replace this line with protection code.

If there is line like this:

# {No PyArmor Protection Code}

PyArmor will not patch this script.

If both of lines aren’t found, insert protection code before the line:

if__name__=='__main__'

Do nothing if no__main__ line found.

Here it’s the default template of protection code:

defprotect_pytransform():importpytransformdefcheck_obfuscated_script():CO_SIZES=49,46,38,36CO_NAMES=set(['pytransform','pyarmor_runtime','__pyarmor__','__name__','__file__'])co=pytransform.sys._getframe(3).f_codeifnot((set(co.co_names)<=CO_NAMES)and(len(co.co_code)inCO_SIZES)):raiseRuntimeError('Unexpected obfuscated script')defcheck_mod_pytransform():def_check_co_key(co,v):return(len(co.co_names),len(co.co_consts),len(co.co_code))==vfork,(v1,v2,v3)in{keylist}:co=getattr(pytransform,k).{code}ifnot_check_co_key(co,v1):raiseRuntimeError('unexpected pytransform.py')ifv2:ifnot_check_co_key(co.co_consts[1],v2):raiseRuntimeError('unexpected pytransform.py')ifv3:ifnot_check_co_key(co.{closure}[0].cell_contents.{code},v3):raiseRuntimeError('unexpected pytransform.py')defcheck_lib_pytransform():filename=pytransform.os.path.join({rpath},{filename})size={size}n=size>>2withopen(filename,'rb')asf:buf=f.read(size)fmt='I'*nchecksum=sum(pytransform.struct.unpack(fmt,buf))&0xFFFFFFFFifnotchecksum=={checksum}:raiseRuntimeError("Unexpected%s"%filename)try:check_obfuscated_script()check_mod_pytransform()check_lib_pytransform()exceptExceptionase:print("Protection Fault:%s"%e)pytransform.sys.exit(1)protect_pytransform()

All the string template{xxx} will be replaced with real value by PyArmor.

To prevent PyArmor from inserting this protection code, pass--no-cross-protection as obfuscating the scripts:

pyarmorobfuscate--no-cross-protectionfoo.py

After the entry script is obfuscated, theBootstrap Code will be insertedat the beginning of the obfuscated script.

How to Run Obfuscated Script

How to run obfuscated scriptdist/foo.py by Python Interpreter?

The first 2 lines, which calledBootstrapCode:

frompytransformimportpyarmor_runtimepyarmor_runtime()

It will fulfil the following tasks

  • Load dynamic library_pytransform byctypes
  • Checklicense.lic is valid or not
  • Add 3 cfunctions to modulebuiltins:__pyarmor__,__armor_enter__,__armor_exit__

The next code line indist/foo.py is:

__pyarmor__(__name__,__file__,b'\x01\x0a...')

__pyarmor__ is called, it will import original module from obfuscated code:

staticPyObject*__pyarmor__(char*name,char*pathname,unsignedchar*obfuscated_code){char*string_code=restore_obfuscated_code(obfuscated_code);PyCodeObject*co=marshal.loads(string_code);returnPyImport_ExecCodeModuleEx(name,co,pathname);}

After that, in the runtime of this python interpreter

  • __armor_enter__ is called as soon as code object is executed, it willrestore byte-code of this code object:

    staticPyObject*__armor_enter__(PyObject*self,PyObject*args){//GotcodeobjectPyFrameObject*frame=PyEval_GetFrame();PyCodeObject*f_code=frame->f_code;//Increaserefcallsofthiscodeobject//Borrowco_names->ob_refcntascallcounter//GenerallyitwillnotincreasedbyPythonInterpreterPyObject*refcalls=f_code->co_names;refcalls->ob_refcnt++;//Restorebytecodeifit's obfuscatedif(IS_OBFUSCATED(f_code->co_flags)){restore_byte_code(f_code->co_code);clear_obfuscated_flag(f_code);}Py_RETURN_NONE;}
  • __armor_exit__ is called so long as code object completed execution, itwill obfuscate byte-code again:

    staticPyObject*__armor_exit__(PyObject*self,PyObject*args){//GotcodeobjectPyFrameObject*frame=PyEval_GetFrame();PyCodeObject*f_code=frame->f_code;//DecreaserefcallsofthiscodeobjectPyObject*refcalls=f_code->co_names;refcalls->ob_refcnt--;//Obfuscatebytecodeonlyifthiscodeobjectisn't used by any function//Inmulti-threadsorrecursivecall,onecodeobjectmaybereferenced//bymanyfunctionsatthesametimeif(refcalls->ob_refcnt==1){obfuscate_byte_code(f_code->co_code);set_obfuscated_flag(f_code);}//Clearf_localsinthisframeclear_frame_locals(frame);Py_RETURN_NONE;}

How To Pack Obfuscated Scripts

The obfuscated scripts generated by PyArmor can replace Python scriptsseamlessly, but there is an issue when packing them into one bundle byPyInstaller:

All the dependencies of obfuscated scripts CAN NOT be found at all

To solve this problem, the common solution is

  1. Find all the dependencies by original scripts.
  2. Add runtimes files required by obfuscated scripts to the bundle
  3. Replace original scripts with obfuscated in the bundle
  4. Replace entry script with obfuscated one

PyArmor provides commandpack to achieve this. But in some cases maybe itdoesn’t work. This document describes what the commandpack does, and alsocould be as a guide to bundle the obfuscated scripts by yourself.

First installpyinstaller:

pipinstallpyinstaller

Then obfuscate scripts todist/obf:

pyarmorobfuscate--outputdist/obf--package-runtime0hello.py

Next generate specfile, add runtime files required by obfuscated scripts:

pyi-makespec--add-datadist/obf/license.lic:. \--add-datadist/obf/pytransform.key:. \--add-datadist/obf/_pytransform.*:. \-pdist/obf--hidden-importpytransform \hello.py

If the scripts are obfuscated by super mode:

pyarmorobfuscate--outputdist/obf--advanced2--package-runtime0hello.py

Generate.spec file by this command:

pyi-makespec-pdist/obf--hidden-importpytransformhello.py
In windows, the: should be replace with; in the command line.

And patch specfilehello.spec, insert the following lines after theAnalysis object. The purpose is to replace all the original scripts withobfuscated ones:

src=os.path.abspath('.')obf_src=os.path.abspath('dist/obf')foriinrange(len(a.scripts)):ifa.scripts[i][1].startswith(src):x=a.scripts[i][1].replace(src,obf_src)ifos.path.exists(x):a.scripts[i]=a.scripts[i][0],x,a.scripts[i][2]foriinrange(len(a.pure)):ifa.pure[i][1].startswith(src):x=a.pure[i][1].replace(src,obf_src)ifos.path.exists(x):ifhasattr(a.pure,'_code_cache'):withopen(x)asf:a.pure._code_cache[a.pure[i][0]]=compile(f.read(),a.pure[i][1],'exec')a.pure[i]=a.pure[i][0],x,a.pure[i][2]

Run patched specfile to build final distribution:

pyinstaller--clean-yhello.spec

Note

Option--clean is required, otherwise the obfuscated scripts will not bereplaced because the cached.pyz will be used.

Check obfuscated scripts work:

dist/hello/hello.exe