VAITP Dataset

← Back to the dataset

CVE-2026-31799

Authenticated SQL injection in Tautulli's get_home_stats API endpoint.

  • CVSS 4.9
  • CWE-20
  • Input Validation and Sanitization
  • Remote

Tautulli is a Python based monitoring and tracking tool for Plex Media Server. From version 2.14.2 to before version 2.17.0 for parameters "before" and "after" and from version 2.1.0-beta to before version 2.17.0 for parameters "section_id" and "user_id", the /api/v2?cmd=get_home_stats endpoint passes the section_id, user_id, before, and after query parameters directly into SQL via Python %-string formatting without parameterization. An attacker who holds the Tautulli admin API key can inject arbitrary SQL and exfiltrate any value from the Tautulli SQLite database via boolean-blind inference. This issue has been patched in version 2.17.0.

CVSS base score
4.9
Published
2026-03-30
OWASP
A03 Injection
Orthogonal defect classification
Checking
Code defect classification
Incorrect Algorithm
Category
Input Validation and Sanitization
Subcategory
SQL Injection
Accessibility scope
Remote
Impact
Information Disclosure
Affected component
Tautulli
Fixed by upgrading
Yes

Solution

Upgrade Tautulli to version 2.17.0 or later.

Vulnerable code sample

# DISCLAIMER: This code is for educational purposes only.
# It is a representation of a vulnerability and should not be used in production.

import sqlite3
from flask import Flask, request, jsonify

# --- Setup for demonstration ---
# This part simulates the Tautulli environment
app = Flask(__name__)

def setup_database():
    """Creates a dummy database and table to simulate the environment."""
    conn = sqlite3.connect(':memory:') # Use in-memory DB for this example
    cursor = conn.cursor()
    cursor.execute('''
        CREATE TABLE session_history (
            id INTEGER PRIMARY KEY,
            user_id INTEGER,
            section_id INTEGER,
            started INTEGER,
            stopped INTEGER
        )
    ''')
    # Add some dummy data
    cursor.execute("INSERT INTO session_history (user_id, section_id, started) VALUES (1, 10, 1640995200)")
    cursor.execute("INSERT INTO session_history (user_id, section_id, started) VALUES (2, 20, 1641081600)")
    conn.commit()
    return conn

db_connection = setup_database()
# --- End of setup ---


# The following function represents the vulnerable code as described in CVE-2026-31799
@app.route('/api/v2', methods=['GET'])
def get_home_stats():
    """
    A representation of the vulnerable /api/v2?cmd=get_home_stats endpoint.
    
    This endpoint is vulnerable to SQL injection because it uses %-string formatting
    to insert user-provided parameters directly into an SQL query.

    Example of a malicious request for boolean-blind inference:
    /api/v2?cmd=get_home_stats&section_id=1) AND (SELECT 1 FROM session_history LIMIT 1) = 1 --
    
    If the above returns a normal result, the subquery was true.
    If it returns an error or different result, the subquery was false.
    An attacker can use this to exfiltrate data character by character.
    """
    if request.args.get('cmd') != 'get_home_stats':
        return jsonify({"response": {"result": "error", "message": "Invalid command"}}), 400

    # Get parameters from the request URL
    section_id = request.args.get('section_id')
    user_id = request.args.get('user_id')
    after = request.args.get('after')
    before = request.args.get('before')

    cursor = db_connection.cursor()

    # The vulnerable query construction using %-string formatting
    query = "SELECT COUNT(*) as history_count FROM session_history WHERE 1=1"
    
    if section_id:
        query += " AND section_id = %s" % section_id
    
    if user_id:
        query += " AND user_id = %s" % user_id
    
    if after:
        query += " AND started > %s" % after
        
    if before:
        query += " AND started < %s" % before
        
    try:
        # The unsanitized query is executed here
        cursor.execute(query)
        result = cursor.fetchone()
        count = result[0] if result else 0
        
        # Return a simulated successful response
        return jsonify({
            "response": {
                "result": "success",
                "message": None,
                "data": {
                    "stat_id": "get_home_stats",
                    "rows": [{"history_count": count}]
                }
            }
        })
    except Exception as e:
        # Errors from malformed SQL injection payloads would be caught here,
        # which is essential for blind inference attacks.
        return jsonify({"response": {"result": "error", "message": str(e)}}), 500

# To run this demonstration:
# 1. Make sure you have Flask installed (`pip install Flask`).
# 2. Run this Python script.
# 3. Access http://127.0.0.1:8181/api/v2?cmd=get_home_stats&section_id=10 in your browser.
# 4. To see the exploit, try an injection payload:
#    http://127.0.0.1:8181/api/v2?cmd=get_home_stats&section_id=1) AND 1=1 --
#    http://127.0.0.1:8181/api/v2?cmd=get_home_stats&section_id=1) AND 1=0 --
if __name__ == '__main__':
    app.run(port=8181)

Patched code sample

import sqlite3

def get_home_stats_patched(db_cursor, user_id=None, section_id=None, before=None, after=None):
    """
    This function represents the patched code that fixes the SQL injection
    vulnerability in Tautulli's get_home_stats endpoint (CVE-2024-31799).

    The fix involves replacing unsafe string formatting with parameterized queries.
    Instead of inserting user input directly into the SQL string, '?' placeholders
    are used. The actual values are passed in a separate list to the `execute`
    method, which allows the database driver to handle them safely.
    """
    # The base query with a placeholder for the WHERE clauses.
    query = """
        SELECT
            COUNT(h.id) AS plays
        FROM session_history AS h
        {where_clause}
    """

    # A list to hold the parameters for safe substitution.
    params = []
    # A list to build the components of the WHERE clause.
    where_clauses = []

    if user_id:
        # The clause uses a '?' placeholder instead of the raw value.
        where_clauses.append("h.user_id = ?")
        # The user-provided value is added to the parameters list.
        params.append(user_id)

    if section_id:
        # This subquery also uses a '?' placeholder for safety.
        where_clauses.append(
            "h.grandparent_rating_key IN "
            "(SELECT rating_key FROM section_locations WHERE section_id = ?)"
        )
        # The user-provided value is added to the parameters list.
        params.append(section_id)

    if before:
        # The clause uses a '?' placeholder.
        where_clauses.append("h.started <= ?")
        # The user-provided value is added to the parameters list.
        params.append(before)

    if after:
        # The clause uses a '?' placeholder.
        where_clauses.append("h.started >= ?")
        # The user-provided value is added to the parameters list.
        params.append(after)

    where_string = ""
    if where_clauses:
        # This join is safe because `where_clauses` only contains predefined
        # SQL fragments and '?' placeholders, not raw user input.
        where_string = "WHERE " + " AND ".join(where_clauses)

    # Format the query by inserting the safe, constructed WHERE clause string.
    final_query = query.format(where_clause=where_string)

    # The database driver (e.g., sqlite3) safely combines the query string
    # and the parameters list, preventing SQL injection.
    db_cursor.execute(final_query, params)

    return db_cursor.fetchone()

Payload

1 AND (SELECT SUBSTR(api_key, 1, 1) FROM users WHERE id = 1) = 'a'

Cite this entry

@misc{vaitp:cve202631799,
  title        = {{Authenticated SQL injection in Tautulli's get_home_stats API endpoint.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-31799},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-31799/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::