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.
- CWE
- CWE-20
- 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§ion_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§ion_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§ion_id=1) AND 1=1 --
# http://127.0.0.1:8181/api/v2?cmd=get_home_stats§ion_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 ::
