363 lines
14 KiB
TypeScript
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;' },
|
|
];
|