Files
0pt/tests/analytics/analyst-sql-bypass-corpus.ts
T

363 lines
14 KiB
TypeScript

/**
* tests/analytics/analyst-sql-bypass-corpus.ts
* =============================================
* BI-P017 — the bypass attempts, in one place, so the SAME corpus can be run
* twice: once against the guard (unit) and once against a LIVE ClickHouse with
* the guard REMOVED (integration), where the grants and the view predicate
* must refuse it anyway.
*
* Running it only against the guard would prove the guard, not the system.
* This estate has repeatedly shipped guards that were never tested against a
* real attempt: INF0's tenant-from-body hole was exploitable against LIVE
* production, and ST0RE turned out to have FOUR cross-tenant paths, not the
* one reported.
*
* The integration test asserts that with the guard removed the engine either
* ERRORS or returns zero foreign rows — never that "the guard said no", which
* is the assertion that would keep passing while the system was open.
*
* Session under test: tenant `t_alpha`, catalog `t_alpha_sql`, objects
* `sales` and `events`. The targets are `t_beta_sql`, the shared base database
* `opt_analytics`, and `system`.
*/
export interface BypassAttempt {
id: string;
/** What the attacker is trying to do. */
goal: string;
sql: string;
}
export const BYPASS_CORPUS: BypassAttempt[] = [
// ── reach another tenant's catalog ──────────────────────────────────────
{
id: 'cross-catalog-direct',
goal: "read the other tenant's catalog",
sql: 'SELECT * FROM t_beta_sql.sales',
},
{
id: 'cross-catalog-union',
goal: 'union in the other tenant',
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT customer FROM t_beta_sql.sales',
},
{
id: 'cross-catalog-subquery',
goal: 'subquery on the other tenant',
sql: 'SELECT (SELECT count() FROM t_beta_sql.sales) AS n FROM t_alpha_sql.sales LIMIT 1',
},
{
id: 'cross-catalog-join',
goal: 'join the other tenant',
sql: 'SELECT a.customer FROM t_alpha_sql.sales a JOIN t_beta_sql.sales b ON a.customer = b.customer',
},
{
id: 'cross-catalog-cte',
goal: 'hide the other tenant in a CTE',
sql: 'WITH other AS (SELECT * FROM t_beta_sql.sales) SELECT count() AS n FROM other',
},
{
id: 'cross-catalog-quoted',
goal: 'quote the identifier to dodge the scan',
sql: 'SELECT * FROM "t_beta_sql"."sales"',
},
{
id: 'cross-catalog-backtick',
goal: 'backtick the identifier',
sql: 'SELECT * FROM `t_beta_sql`.`sales`',
},
// ── reach the unfiltered base table ─────────────────────────────────────
{
id: 'base-table-direct',
goal: 'read the shared base table, which holds every tenant',
sql: 'SELECT * FROM opt_analytics.opt_analytics_event',
},
{
id: 'base-table-in-cte',
goal: 'base table behind a CTE',
sql: 'WITH raw AS (SELECT * FROM opt_analytics.opt_analytics_event) SELECT count() AS n FROM raw',
},
{
id: 'base-table-union',
goal: 'union the base table in',
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT tenant_id FROM opt_analytics.opt_analytics_event',
},
// ── system / metadata catalogs ──────────────────────────────────────────
{
id: 'system-tables',
goal: 'enumerate every table on the server',
sql: 'SELECT database, name FROM system.tables',
},
{ id: 'system-columns', goal: 'enumerate columns', sql: 'SELECT * FROM system.columns' },
{ id: 'system-users', goal: 'enumerate users', sql: 'SELECT * FROM system.users' },
{
id: 'system-processes',
goal: "read other tenants' running queries",
sql: 'SELECT query FROM system.processes',
},
{
id: 'system-query-log',
goal: "read other tenants' statement text out of the log",
sql: 'SELECT query FROM system.query_log',
},
{
id: 'information-schema',
goal: 'metadata via the ANSI schema',
sql: 'SELECT * FROM information_schema.tables',
},
{ id: 'one-table', goal: 'the always-present one-row table', sql: 'SELECT * FROM system.one' },
// ── table functions: these need NO table privilege at all ───────────────
{
id: 'tf-url',
goal: 'exfiltrate over HTTP',
sql: "SELECT * FROM url('http://127.0.0.1:1/x', 'JSONEachRow', 'a String')",
},
{
id: 'tf-file',
goal: 'read the server filesystem',
sql: "SELECT * FROM file('/etc/passwd', 'LineAsString', 'l String')",
},
{
id: 'tf-s3',
goal: 'read object storage directly',
sql: "SELECT * FROM s3('http://minio/b/*', 'CSV', 'a String')",
},
{
id: 'tf-remote',
goal: 'query another ClickHouse server',
sql: "SELECT * FROM remote('127.0.0.1:9000', 'opt_analytics', 'opt_analytics_event')",
},
{
id: 'tf-mysql',
goal: 'reach an external database',
sql: "SELECT * FROM mysql('h:3306', 'db', 't', 'u', 'p')",
},
{ id: 'tf-executable', goal: 'run a program', sql: "SELECT * FROM executable('sh', 'TSV', 'a String')" },
{
id: 'tf-numbers',
goal: 'a CPU bomb that touches no table',
sql: 'SELECT count() FROM numbers_mt(100000000000)',
},
{ id: 'tf-generate', goal: 'unbounded synthetic scan', sql: "SELECT * FROM generateRandom('a String')" },
{
id: 'tf-merge',
goal: 'merge() glob across a database',
sql: "SELECT * FROM merge('opt_analytics', '.*')",
},
{
id: 'tf-cluster',
goal: 'fan out across the cluster',
sql: "SELECT * FROM cluster('default', 'opt_analytics', 'opt_analytics_event')",
},
{
id: 'tf-view',
goal: 'wrap a subquery in view()',
sql: 'SELECT * FROM view(SELECT * FROM opt_analytics.opt_analytics_event)',
},
{
id: 'fn-dictget',
goal: 'read a dictionary, bypassing table grants',
sql: "SELECT dictGet('d', 'v', toUInt64(1)) AS x FROM t_alpha_sql.sales",
},
// ── write / DDL / privilege escalation ──────────────────────────────────
{
id: 'stacked-drop',
goal: 'stacked statement',
sql: 'SELECT 1 FROM t_alpha_sql.sales; DROP TABLE opt_analytics.opt_analytics_event',
},
{ id: 'insert', goal: 'write', sql: "INSERT INTO t_alpha_sql.sales VALUES ('x')" },
{
id: 'insert-select',
goal: 'write from a read',
sql: 'INSERT INTO t_alpha_sql.sales SELECT * FROM t_alpha_sql.sales',
},
{ id: 'alter-delete', goal: 'mutate', sql: 'ALTER TABLE opt_analytics.opt_analytics_event DELETE WHERE 1' },
{
id: 'create-view-escape',
goal: 'create an unfiltered view to read later',
sql: 'CREATE VIEW t_alpha_sql.escape AS SELECT * FROM opt_analytics.opt_analytics_event',
},
{ id: 'grant-self', goal: 'grant itself more', sql: 'GRANT SELECT ON opt_analytics.* TO CURRENT_USER' },
{ id: 'system-command', goal: 'operate the server', sql: 'SYSTEM RELOAD DICTIONARIES' },
{ id: 'kill-query', goal: "kill another tenant's query", sql: 'KILL QUERY WHERE 1' },
{ id: 'truncate', goal: 'destroy data', sql: 'TRUNCATE TABLE t_alpha_sql.sales' },
{
id: 'optimize',
goal: 'trigger heavy background work',
sql: 'OPTIMIZE TABLE opt_analytics.opt_analytics_event FINAL',
},
{
id: 'outfile',
goal: 'write to the filesystem',
sql: "SELECT * FROM t_alpha_sql.sales INTO OUTFILE '/tmp/x.csv'",
},
// ── lift the governance ceiling from inside the statement ───────────────
{
id: 'settings-raise-rows',
goal: 'raise max_rows_to_read',
sql: 'SELECT count() AS n FROM t_alpha_sql.sales SETTINGS max_rows_to_read = 0',
},
{
id: 'settings-readonly-off',
goal: 'turn readonly off',
sql: 'SELECT 1 FROM t_alpha_sql.sales SETTINGS readonly = 0',
},
{ id: 'set-statement', goal: 'SET before the query', sql: 'SET max_execution_time = 0' },
{
id: 'format-override',
goal: 'choose the wire format',
sql: 'SELECT * FROM t_alpha_sql.sales FORMAT Native',
},
// ── lexical evasion of the keyword scan ─────────────────────────────────
{
id: 'comment-split-name',
goal: 'hide a catalog boundary behind a comment',
sql: 'SELECT * FROM opt_analytics/**/.opt_analytics_event',
},
{
id: 'comment-line-hide',
goal: 'hide the tail after a -- comment',
sql: 'SELECT customer FROM t_alpha_sql.sales -- \nUNION ALL SELECT name FROM system.tables',
},
{
id: 'comment-hash-hide',
goal: 'hide the tail after a # comment',
sql: 'SELECT customer FROM t_alpha_sql.sales # \nUNION ALL SELECT name FROM system.tables',
},
{
id: 'unterminated-comment',
goal: 'run off the end of a comment',
sql: 'SELECT * FROM t_alpha_sql.sales /* x',
},
{
id: 'unterminated-string',
goal: 'run off the end of a literal',
sql: "SELECT * FROM t_alpha_sql.sales WHERE customer = 'x",
},
{
id: 'unicode-homoglyph',
// U+0430 CYRILLIC SMALL LETTER A in place of the ASCII "a".
goal: 'cyrillic homoglyph inside the catalog name',
sql: 'SELECT * FROM t_\u0430lpha_sql.sales',
},
{
id: 'zero-width',
// U+200B ZERO WIDTH SPACE inside "system".
goal: 'zero-width space inside a reserved catalog name',
sql: 'SELECT * FROM sys\u200btem.tables',
},
{
id: 'nul-byte',
goal: 'NUL truncation between two halves of a statement',
sql: 'SELECT customer FROM t_alpha_sql.sales \u0000 UNION ALL SELECT name FROM system.tables',
},
{ id: 'case-mixing', goal: 'mixed case keywords and names', sql: 'SeLeCt * FrOm SyStEm.TaBlEs' },
{
id: 'whitespace-newlines',
goal: 'newlines and tabs between tokens',
sql: 'SELECT\n\t*\nFROM\n\tsystem.tables',
},
{
id: 'describe-subquery',
goal: 'DESCRIBE a base table from inside a subquery, where the FROM-scan sees only a paren',
sql: 'SELECT * FROM (DESCRIBE TABLE opt_analytics.opt_analytics_event)',
},
{
id: 'desc-subquery-short',
goal: 'the same, using the DESC abbreviation',
sql: 'SELECT * FROM (DESC TABLE opt_analytics.opt_analytics_event)',
},
{
id: 'current-user',
goal: "identify the session's ClickHouse principal",
sql: 'SELECT currentUser() AS u FROM t_alpha_sql.sales',
},
{ id: 'three-part-name', goal: 'a three-part name', sql: 'SELECT * FROM a.b.c' },
{
id: 'unqualified-table',
goal: "unqualified name resolved by the connection's default database",
sql: 'SELECT * FROM opt_analytics_event',
},
{
id: 'cte-shadow',
goal: 'shadow a legitimate name with a CTE while also reading the real base table',
sql: 'WITH sales AS (SELECT 1 AS x) SELECT count() AS n FROM opt_analytics.opt_analytics_event',
},
{ id: 'no-from', goal: 'a statement that reads no table at all', sql: 'SELECT 1' },
{
id: 'object-not-granted',
goal: 'an object in the right catalog that the session was not given',
sql: 'SELECT * FROM t_alpha_sql.secret_ledger',
},
];
/**
* Attempts the GUARD ADMITS — and which only layers 3 and 4 refuse.
*
* This list exists because the first run of the corpus found one, and hiding
* it by quietly deleting the case would have been the dishonest fix. The guard
* reasons about statement STRUCTURE; it does not know which columns a view
* projects, and teaching it would mean giving it a schema cache that goes
* stale — a hardcoded list, which is how api0's container job died this week.
*
* `SELECT tenant_id FROM t_alpha_sql.sales` is structurally a legitimate read
* of an allowed object. It fails at the ENGINE, because the view does not
* project `tenant_id` at all (tenant-catalog.ts refuses to build one that
* does). The integration test asserts that engine-side refusal directly, so
* this case is covered by evidence rather than by an assumption.
*/
export const GUARD_ADMITS_ENGINE_REFUSES: BypassAttempt[] = [
{
id: 'hidden-tenant-column',
goal: 'select the tenant column the view does not project',
sql: 'SELECT tenant_id FROM t_alpha_sql.sales',
},
{
id: 'hidden-column-star-then-filter',
goal: 'filter on a column the view does not expose',
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE tenant_id = 't_beta'",
},
];
/** Statements that MUST be admitted — a guard that refuses everything is useless. */
export const LEGITIMATE_CORPUS: Array<{ id: string; sql: string }> = [
{ id: 'simple', sql: 'SELECT customer, amount FROM t_alpha_sql.sales LIMIT 10' },
{
id: 'aggregate',
sql: 'SELECT customer, sum(amount) AS total FROM t_alpha_sql.sales GROUP BY customer ORDER BY total DESC LIMIT 5',
},
{
id: 'cte',
sql:
'WITH top AS (SELECT customer, sum(amount) AS total FROM t_alpha_sql.sales GROUP BY customer) ' +
'SELECT customer, total FROM top ORDER BY total DESC LIMIT 3',
},
{
id: 'self-join',
sql: 'SELECT a.customer FROM t_alpha_sql.sales a JOIN t_alpha_sql.events b ON a.customer = b.customer LIMIT 10',
},
{
id: 'literal-with-quotes',
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE customer = 'O''Brien'",
},
{
id: 'literal-containing-sql-text',
sql: "SELECT count() AS n FROM t_alpha_sql.sales WHERE customer = 'DROP TABLE system.tables'",
},
{ id: 'comment-then-query', sql: '-- daily revenue\nSELECT sum(amount) AS total FROM t_alpha_sql.sales' },
{
id: 'subquery',
sql: 'SELECT customer FROM t_alpha_sql.sales WHERE amount > (SELECT avg(amount) FROM t_alpha_sql.sales)',
},
{
id: 'union-own-catalog',
sql: 'SELECT customer FROM t_alpha_sql.sales UNION ALL SELECT customer FROM t_alpha_sql.events',
},
{ id: 'trailing-semicolon', sql: 'SELECT count() AS n FROM t_alpha_sql.sales;' },
];