Simple script protection in Python

Stack: Python 3.11.7, ntplib, subprocess, getpass for time, system, password,

PyArmor 8+ for obfuscation.

Scenario: An application has been developed that gives an advantage over competitors or contains confidential data. The application is to be installed on several employees' devices (the number can be increased by slightly changing the approach), and you do not plan to transfer part of the logic to the server.

Other scenarios are possible, I described the most likely case in my opinion when such protection may be needed.

In this article, I will tell you several ways that will prevent the application from running where it should not, and will most likely discourage anyone from delving into your code.

So, one fine day an ungrateful user leaves for a new company, takes your executable with them, receives bonuses, and rejoices. Meanwhile, the local IT specialist launches Ghidra or any other reverse engineering tool. And the result of your work for a year, a month, a week, a day, ends up with a competitor.

To avoid such a development, we can: 1) bind the script to the hard drive, MAC address, date, device name, motherboard UUID, BIOS/TPM version, IP address, token/certificate, network connection, USB key, and so on (underline as needed) 2) obfuscate (mask) the script itself.

It is not necessary to bind the script to all parameters at once, it is enough to choose a couple of them. For example, if the user will be working with equipment provided by the employer, you can bind the script to the unique characteristics of the device, and if it is assumed that uncontrolled equipment will be used, then use passwords or time limits.

It is especially effective if users do not know which dependencies you used, making it more difficult to replace them on a new device.

Let's move on to the libraries.

1. subprocess

Used to obtain the serial number of the hard drive, ensuring the binding of the program to a specific device.

To obtain the serial number of the hard drive on Windows, in the command line:

wmic diskdrive get serialnumber

Checking the serial number of the hard drive:

def get_disk_serial_number():
    try:
        output = subprocess.check_output('wmic diskdrive get serialnumber', shell=True)
        serial = output.decode().split("\n")[1].strip()
        return serial
    except Exception as e:
        return None

def check_disk_serial(expected_serial):
    serial_number = get_disk_serial_number()
    return serial_number == expected_serial

2. ntplib

Used to obtain the exact time from the NTP server, which prevents manipulation of the system time.

We can limit the duration of our script, for example, until 31.12.2024, and then even if the employee takes the hard drive with them, the program will stop working after a while.

Function to get time from NTP server:

def get_time_from_ntp():
    try:
        ntp_client = ntplib.NTPClient()
        response = ntp_client.request('pool.ntp.org')  # Using a public NTP server
        ntp_time = datetime.fromtimestamp(response.tx_time, tz=timezone.utc)  # Convert time to UTC format
        return ntp_time
    except Exception as e:
        print(f"Error accessing NTP server: {e}")
        return None

def check_expiration_date(expiration_date_str):
    expiration_date = datetime.strptime(expiration_date_str, "%Y-%m-%d").date()  # Convert to date

    # Get the current date from the NTP server
    current_date = get_time_from_ntp()

    if current_date is None:
        print("Error: failed to get the current date from the NTP server.")
        return False  # If the date could not be obtained, return an error and do not continue execution

    if current_date.date() <= expiration_date:  # Convert current_date to date for comparison
        return True
    else:
        return False

1-2. Checking all conditions

def check_conditions():
    expiration_date_str = "2024-12-31"  # Expiration date
    expected_serial = "ABC123"  # Hard drive serial number

    # Check expiration date
    if not check_expiration_date(expiration_date_str):
        print("Error: the license has expired or failed to get the current date.")
        return False

    # Check disk serial number
    if not check_disk_serial(expected_serial):
        print("Error: incorrect disk serial number.")
        return False

    print("All checks passed successfully.")
    return True

Thus, the script will run only on the specified devices until the expiration date, similarly, it can be done with other parameters.

3. getpass (optional)

Used for secure password input without displaying it on the screen.

In principle, passwords do not necessarily need to be entered considering the two previous conditions, but if you want to increase security, why not.

Adding a function to get the current month:

def get_current_month():
    current_time = get_time_from_ntp()  # Get the current time from the NTP server
    if current_time is None:
        return None
    return current_time.month  # Extract the current month number

Function to check the password considering the month:

def check_password():
    month_number = get_current_month()  # Get the current month
    if month_number is None:
        print("Error: failed to get the current month.")
        return False

    # Passwords for each month
    passwords_by_month = {
        1: "jan_pass_43kX",
        2: "feb_pass_wQ84",
        3: "mar_pass_29LZ",
        4: "apr_pass_fG12",
        5: "may_pass_98Xy",
        6: "jun_pass_Ue47",
        7: "jul_pass_kP93",
        8: "aug_pass_qB21",
        9: "sep_pass_Tm56",
        10: "oct_pass_Lz77",  
        11: "nov_pass_Ew32",
        12: "dec_pass_Vj89"
    }

    # Get the correct password for the current month
    correct_password = passwords_by_month.get(month_number)

Next is an example using tkinter:

    # Create the main window
    root = tk.Tk()
    root.withdraw()  # Hide the main window

    # Open the dialog window to enter the password
    entered_password = simpledialog.askstring("Password", f"Enter the password for month {month_number}:", show='*')

    if entered_password == correct_password:
        print("Access granted.")
        return True
    else:
        print("Incorrect password. Access denied.")
        return False

In the initial code, it is better to specify the real reasons for access denial. Once you make sure everything works, I recommend replacing all errors with "Access denied", making it harder to determine why the script is not working.



Alt for image 2: Python code demonstrating basic script protection by checking the password before execution.

PyArmor 8+

The subcommands in PyArmor 8+ have changed compared to previous versions. I did not find a description of the new subcommands in Russian, which is partly why I decided to write this article.

Fortunately, the documentation is well-written and did not take much time.

In PyArmor8+ there are actually 3 subcommands:

1. reg/man - used to register a new license or update an existing PyArmor license.
2. gen (generate, g) - generates obfuscated scripts and necessary runtime files.

3. cfg - displays and configures PyArmor environment settings.

In the paid version of PyArmor 8+, there are specific commands for binding the script to the device and setting the license expiration date.

pyarmor gen -O dist4 -e 30 script.py - script with an expiration date of 30 days.

pyarmor gen -O dist5 -b "00:16:3e:35:19:3d HXS2000CN2A" script.py - the computer will be able to run the script only if the Ethernet address and hard drive match.

Step-by-step obfuscation (available in the free version, with extended functionality in the paid version):

  1. Obfuscate the original script:

    pyarmor gen -O dist script.py

    -O dist – create directory

  2. Obfuscation of some modules may cause errors at runtime, as they are expected in their original form, then:

    pyarmor gen -O dist --exclude tkinter script.py

    --exclude tkinter – excludes the tkinter module from the obfuscation process.

  3. Package the obfuscated script with PyInstaller:

pyinstaller --clean --onefile --icon=icon.ico --add-data "any.csv;." --hidden-import pandas --hidden-import numpy --hidden-import tkinter --collect-all tkinter script.py

--clean - ensures that the build is done from scratch.

--onefile - creates a single executable file without additional folders

--icon=icon.ico – icon

--add-data "any.csv;." - adds files necessary for the script to work

Important point when building pyinstaller, it determines which libraries are used in your code and adds them to the executable, but when you package an obfuscated script, pyinstaller often cannot determine which libraries you used, so:

--hidden-import pandas - adds the libraries needed for the script to work

If --hidden-import is not enough and pyinstaller incorrectly builds, you can see which libraries are missing (it will be visible in the command line when running) and use:

--collect-all tkinter - ensures that all files and dependencies (scripts, modules, and resources) for tkinter are collected and added to the final executable file.

In summary, points 1-3 of the article are aimed at protecting the application from unauthorized use by the user, and PyArmor prevents attempts at reverse engineering and decompilation.

P.S. Any protection can be broken and PyArmor is no exception, in addition, there are dozens of other ways to get your code or reproduce it by studying the functionality (which is symbolized by the gates without walls in the preview). But for this, you will have to spend time, money, nerves, and all that, and that's a completely different story.

Comments