- Security
- A
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.
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):
Obfuscate the original script:
pyarmor gen -O dist script.py
-O dist
– create directoryObfuscation 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 thetkinter
module from the obfuscation process.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.
Write comment