Links within this blog post contain affiliate links. If you purchase items using these links, trenchesofit may earn a commission. Thank you.
Zoneminder is an open-source surveillance solution that allows recording, monitoring, and analyzing your security cameras. I have been using Zoneminder personally for many years. I recently purchased a PTZ Reolink camera and wanted to integrate control of the camera from the watch page within Zoneminder.
Take me straight to the code: https://github.com/trenchesofit/zoneminder-reolink-plugin
PTZ Camera:
My Reolink camera is the RLC-823A POE IP 4k version. https://amzn.to/3jvwvJ4 (Affiliate link)
Before I begin, I should mention I will use insecure coding practices throughout this blog. My camera environment is housed within a monitored network with layers of security. I coded this solution with my specific threat model in mind.
Getting Started
To start I found some excellent documentation on the Reolink API located here. The first step was to request a token using a POST request containing the username and password for the camera. This response will contain the needed authentication token for all the future calls to the API.
I started building the needed requests using BurpSuite and saving them in Repeater.
Example token POST request:
POST /cgi-bin/api.cgi?cmd=Login HTTP/1.1 Host: 10.0.10.103 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36 Accept: */* Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9 X-Requested-With: XMLHttpRequest Origin: http://10.0.10.103 Referer: http://10.0.10.103/ Connection: close Content-Type: application/json Content-Length: 149 [ { "cmd":"Login", "param":{ "User":{ "userName":"admin", "password":"" } } } ]
Example token POST response:
HTTP/1.1 200 OK Server: nginx/1.6.2 Date: Wed, 01 Feb 2023 23:32:27 GMT Content-Type: text/html Connection: close Content-Length: 184 [ { "cmd" : "Login", "code" : 0, "value" : { "Token" : { "leaseTime" : 3600, "name" : "adceedcae4d2415" } } } ]
Now that I had a token, I needed to get a list of the preset PTZ positions configured on the target camera. This request included a URL containing the received token along with the ptzCtrl command in the body.
Example PTZ list request:
POST /cgi-bin/api.cgi?cmd=GetTime&token=64c5a4d810d029c HTTP/1.1 Host: 10.0.10.103 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36 Accept: */* Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9 X-Requested-With: XMLHttpRequest Origin: http://10.0.10.103 Referer: http://10.0.10.103/ Connection: close Content-Type: application/json Content-Length: 60 [ { "action": 1, "cmd": "GetPtzPreset", "param":{ "channel":0 } } ]
Example PTZ list response:
HTTP/1.1 200 OK Server: nginx/1.6.2 Date: Thu, 02 Feb 2023 01:10:44 GMT Content-Type: text/html Connection: close Content-Length: 9749 [ { "cmd" : "GetPtzPreset", "code" : 0, "range" : { "PtzPreset" : { "channel" : 0, "enable" : "boolean", "id" : { "max" : 64, "min" : 1 }, "name" : { "maxLen" : 31 } } }, "value" : { "PtzPreset" : [ { "channel" : 0, "enable" : 1, "id" : 1, "name" : "Driveway" }, { "channel" : 0, "enable" : 1, "id" : 2, "name" : "DogLot" }, { "channel" : 0, "enable" : 1, "id" : 3, "name" : "Zoomed Driveway" }, { "channel" : 0, "enable" : 1, "id" : 4, "name" : "other one" }, {{--Additional Lines Truncated for Brevity--}}
Next, I took the values from the list PTZ preset response and applied them to the PtzCtrl command to set the camera to the provided preset.
Example PTZ preset request:
POST /cgi-bin/api.cgi?cmd=GetTime&token=64c5a4d810d029c HTTP/1.1 Host: 10.0.10.103 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.120 Safari/537.36 Accept: */* Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9 X-Requested-With: XMLHttpRequest Origin: http://10.0.10.103 Referer: http://10.0.10.103/ Connection: close Content-Type: application/json Content-Length: 73 [ { "cmd": "PtzCtrl", "param":{ "channel":0, "op":"ToPos", "id":1, "speed":32 } } ]
Example PTZ preset response:
HTTP/1.1 200 OK Server: nginx/1.6.2 Date: Thu, 02 Feb 2023 01:06:00 GMT Content-Type: text/html Connection: close Content-Length: 108 [ { "cmd" : "PtzCtrl", "code" : 0, "value" : { "rspCode" : 200 } } ]
We now had all the requests set up as needed. I installed a Burp extension called “Copy As Python Request”. This allowed me to quickly set up a PoC to chain the authentication requests followed by the list and control actions.
Once “Copy As Python-Requests” is installed, you can just right-click the request you would like to code and select the extension.
The first PoC fetches the token by passing the “Login” command with the camera username and password. The token is then passed to the following commands to grab the list of PTZ preset values and call the preset.
Early PoC:
import requests import json burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=Login&token=null" burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"} burp0_json=[{"action": 0, "cmd": "Login", "param": {"User": {"password": "", "userName": "admin"}}}] response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json) print(requests.post) print(response.text) #print(response.text) responseJson = json.loads(response.text) #parse token from response token = (responseJson[0]['value']['Token']['name']) #gets possible ptz preset positions burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=GetTime&token="+token burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"} burp0_json=[{"action": 1, "cmd": "GetPtzPreset","param":{"channel":0}}] response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json) #selects the ptz id burp0_url = "http://10.0.10.103:80/cgi-bin/api.cgi?cmd=GetTime&token="+token burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": "http://10.0.10.103", "Referer": "http://10.0.10.103/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"} burp0_json=[{"cmd": "PtzCtrl","param":{"channel":0,"op":"ToPos","id":1,"speed":32}}] response = requests.post(burp0_url, headers=burp0_headers, json=burp0_json) print(response.text)
Now that we had a working PoC, we needed to configure the buttons within Zoneminder to call the script. The page I added this code to was the “watch.php” page located in “/usr/share/zoneminder/www/skins/classic/views/”. For this, I used the following code.
<form method='post'> <input type='submit' value='Zoomed Driveway' name='GO3'> <?php if(isset($_POST['GO3'])) { shell_exec('python3 ZoomedDriveway.py'); echo'success'; } ?>
I added this code after the buttons above the streaming view. This started on line 71.
This gave me a final look at what the integration will look like.
Final Script
The final script iterates over the configuration file for each preset configured in each camera. A button is created along with a python file to take action for that preset. Suppose a new preset is added to the camera configuration. In that case, the user needs to re-run the script to have the older code removed and the new code put in place.
import requests import json import logging import configparser import re import fileinput import shutil import datetime import sys # noinspection PyArgumentList logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.DEBUG, handlers=[logging.FileHandler("zoneminder-reolink-plugin.log"), logging.StreamHandler()]) # Configuration file read config = configparser.ConfigParser() config.read('secrets.cfg') def generate_code(camera): # Pull values from configuration file try: ip_address = config[camera]['ip'] port = config[camera]['port'] username = config[camera]['username'] password = config[camera]['password'] except Exception as config_error: logging.error(config_error) sys.exit(0) try: # Grabs the needed authentication token login_url = f"http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=Login&token=null" headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", "User-Agent": "Mozilla/5.0 (Windows NT 10.0;" "Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", "Content-Type": "application/json", "Origin": f"http://{ip_address}", "Referer": f"http://{ip_address}/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "en-US,en;q=0.9", "Connection": "close"} login_json = [{"action": 0, "cmd": "Login", "param": {"User": {"password": f"{password}", "userName": f"{username}"}}}] response = requests.post(login_url, headers=headers, json=login_json) login_response_json = json.loads(response.text) token = (login_response_json[0]['value']['Token']['name']) # Gets possible ptz preset positions preset_url = f"http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=GetTime&token={token}" preset_json = [{"action": 1, "cmd": "GetPtzPreset", "param": {"channel": 0}}] response = requests.post(preset_url, headers=headers, json=preset_json) preset_response_json = json.loads(response.text) preset_data = (preset_response_json[0]['value']['PtzPreset']) # Iterates through preset PTZ configuration to create PHP code for preset in preset_data: if preset['enable'] == 1: # Removes spaces in preset names safe_name = preset['name'].replace(" ", "") # Generates PHP code to add buttons to watch.php file php_code = f" <form method='post'>\n <input type='submit' value='" + preset['name'] + "' name='GO" + \ str(preset['id']) + "'>\n <?php \n if(isset($_POST['GO" + str(preset['id']) + "']))\n { \n"\ "shell_exec('python3 " + safe_name + ".py'); \n echo'success'; \n } \n ?>\n" \ "<!-- End of camera configuration.-->" comment = f"<!-- This configuration is for reolink integration for camera " \ f"{preset['name']} on preset {preset}-->" # Generates Python code to create action files python_code = f'import requests\nimport json\nusername = "{username}"\npassword = "{password}"\n' \ f'burp0_url = "http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=Login&token=' \ 'null"\nburp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest",' \ '"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, ' \ 'like Gecko) Chrome/109.0.5414.75 Safari/537.36","Content-Type": "application/json", ' \ f'"Origin": "http://{ip_address}","Referer": "http://{ip_address}/", "Accept-Encoding": '\ '"gzip, deflate","Accept-Language": "en-US,en;q=0.9", "Connection": "close"}\n' \ 'burp0_json = [{"action": 0, "cmd": "Login", "param": {"User": {"password": ""' \ '+password+"", "userName": ""+username+""}}}]\nresponse = requests.post(burp0_url, ' \ 'headers=burp0_headers, json=burp0_json)\nresponseJson = json.loads(response.text)\n' \ 'token = (responseJson[0]["value"]["Token"]["name"])\n' \ f'burp0_url = "http://{ip_address}:{port}/cgi-bin/api.cgi?cmd=GetTime&token="+token \n' \ 'burp0_headers = {"Accept": "*/*", "X-Requested-With": "XMLHttpRequest", \n ' \ '"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' \ '(KHTML, like Gecko) Chrome/109.0.5414.75 Safari/537.36", \n "Content-Type": ' \ f'"application/json", "Origin": "http://{ip_address}", \n "Referer": "http://' \ f'{ip_address}/", "Accept-Encoding": "gzip, deflate", \n "Accept-Language": ' \ '"en-US,en;q=0.9", "Connection": "close"}\nburp0_json = [{"cmd": "PtzCtrl", "param": ' \ '{"channel": 0, "op": "ToPos", "id": ' + str(preset['id']) + ', "speed": 32}}] \n' \ 'response = requests.post (burp0_url, headers=burp0_headers, json=burp0_json)' code_write(php_code, comment, python_code, safe_name) logging.info("Success! Camera presets should now be visible on the camera stream within Zoneminder.") except Exception as error: logging.error(error) # Backup watch file before altering, save with date and time def backup_watch(): logging.info("Backing up the target watch.php file.") try: target_file = "/usr/share/zoneminder/www/skins/classic/views/watch.php" now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") backup_file = f"/usr/share/zoneminder/www/skins/classic/views/watch.{now}.php.bak" shutil.copy(target_file, backup_file) logging.info("Backup successful") except IOError as e: logging.error(e) # Check watch.php for previous configurations pushed to file def code_check(): try: for i, line in enumerate(open('/usr/share/zoneminder/www/skins/classic/views/watch.php')): for match in re.finditer("This configuration is for reolink integration for camera", line): logging.info(f"Previous configuration detected on line {i+1}: {match.group()}") return True except IOError as e: logging.error(e) # Remove previous config / leaves last comment def remove_previous_config(): beginning_comment = "<!-- This configuration is for reolink" ending_comment = "<!-- End of camera configuration.-->" delete_line = False logging.info(f"Removing previous configuration.") try: for line in fileinput.input('/usr/share/zoneminder/www/skins/classic/views/watch.php', inplace=True): if beginning_comment in line: delete_line = True elif ending_comment in line: delete_line = False print(line, end='') elif not delete_line: print(line, end='') except IOError as e: logging.error(e) # Writes code to the watch.php page and creates the needed python file action def code_write(php_code, comment, python_code, safe_name): if php_code: try: with open('/usr/share/zoneminder/www/skins/classic/views/watch.php', "r+") as watch_ui: file_data = watch_ui.read() text_pattern = re.compile(re.escape("fa-exclamation-circle\"></i></button>"), flags=0) file_contents = text_pattern.sub(f"fa-exclamation-circle\"></i></button>\n{comment}\n{php_code}", file_data) watch_ui.seek(0) watch_ui.truncate() watch_ui.write(file_contents) except IOError as e: logging.error(f"Error writing PHP code. {e}") if python_code: try: with open('/usr/share/zoneminder/www/'+safe_name+'.py', 'w') as python_file: python_file.write(python_code) python_file.close() except IOError as e: logging.error(f"Error writing Python code. {e}") def main(): boolean_response = code_check() backup_watch() if boolean_response is None: for camera in config.sections(): generate_code(camera) else: remove_previous_config() for camera in config.sections(): generate_code(camera) return 0 if __name__ == '__main__': exit_code = main() exit(exit_code)
Setup
The configuration file needs to be manually created within the “/usr/share/zoneminder/www/” directory. This will need to be updated to contain your specific configuration per camera.
[camera1] ip=10.0.10.103 port=80 username=admin password= [camera2] ip=10.0.10.104 port=80 username=testusername password=testpassword
Grab the script and place it in your “/usr/share/zoneminder/www/” directory. Depending on your specific configuration you may need to run the script with sudo.
trenchesofit@zoneminder:/usr/share/zoneminder/www$ sudo python3 zoneminder-reolink-plugin.py 2023-02-05 11:43:35,721 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera 2023-02-05 11:43:35,722 - root - INFO - Backing up the target watch.php file. 2023-02-05 11:43:35,723 - root - INFO - Backup successful 2023-02-05 11:43:35,723 - root - INFO - Removing previous configuration. 2023-02-05 11:43:35,730 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:43:35,745 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None 2023-02-05 11:43:35,749 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:43:35,821 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=b4c1965a54516bf HTTP/1.1" 200 None 2023-02-05 11:43:35,845 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder.
Backup
A backup of the original “watch.php” file is created before writing in the event it botches something on the way.
Logging
Logs are stored in a created file named “zoneminder-reolink-plugin.log”
trenchesofit@zoneminder:/usr/share/zoneminder/www$ cat zoneminder-reolink-plugin.log 2023-02-05 11:29:28,839 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera 2023-02-05 11:29:28,839 - root - INFO - Removing previous configuration. 2023-02-05 11:29:28,846 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:29:28,865 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None 2023-02-05 11:29:28,868 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:29:28,973 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=51236b93c14eaaa HTTP/1.1" 200 None 2023-02-05 11:29:29,156 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder. 2023-02-05 11:43:35,721 - root - INFO - Previous configuration detected on line 71: This configuration is for reolink integration for camera 2023-02-05 11:43:35,722 - root - INFO - Backing up the target watch.php file. 2023-02-05 11:43:35,723 - root - INFO - Backup successful 2023-02-05 11:43:35,723 - root - INFO - Removing previous configuration. 2023-02-05 11:43:35,730 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:43:35,745 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=Login&token=null HTTP/1.1" 200 None 2023-02-05 11:43:35,749 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): 10.0.10.103:80 2023-02-05 11:43:35,821 - urllib3.connectionpool - DEBUG - http://10.0.10.103:80 "POST /cgi-bin/api.cgi?cmd=GetTime&token=b4c1965a54516bf HTTP/1.1" 200 None 2023-02-05 11:43:35,845 - root - INFO - Success! Camera presets should now be visible on the camera stream within Zoneminder.
Conclusion
This small project was a nice change of pace from pen-testing and allowed me to build something new. We now have a nice scalable Reolink PTZ preset integration into Zoneminder. I look forward to adding more features and automation to the plugin as time allows. Try out the code for yourself and let me know what you think. I have added the project to GitHub here.
Until next time, stay safe in the Trenches of IT!