Back to Blog
critical SEVERITY4 min read

Actual Budget addTransaction.sh SQL Injection via Shell Variable

A critical SQL injection vulnerability in Actual Budget's transaction automation script allowed attackers to manipulate database records through shell variables interpolated directly into SQL strings. The fix introduces proper escaping functions and numeric validation to prevent injection through unquoted fields.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 18, 2026•Reviewed September 18, 2026

Answer Summary

Actual Budget's addTransaction.sh automation script (first-party code) was vulnerable to SQL injection. An attacker could execute arbitrary SQL commands by injecting malicious values into variables like $ACCOUNT_ID or $AMOUNT that were interpolated unquoted into INSERT statements. The fix adds an `escape_sql()` function with `sed`-based single-quote doubling and regex validation for numeric fields. Fixed in the latest commit addressing CWE-89.

Vulnerability at a Glance

cweCWE-89
fixAdded `escape_sql()` function and numeric validation for unquoted fields
riskCritical — database compromise, data exfiltration, unauthorized transactions
languageShell (Bash)
root causeShell variable interpolation into SQL strings without parameterization
vulnerabilitySQL Injection

Affected Versions

Affected not applicable (first-party code)
Fixed in latest commit
Ecosystem not applicable (shell script)
CVE / GHSA not assigned
CWE CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

Introduction

A critical SQL injection vulnerability reached production in Actual Budget's transaction automation infrastructure, where shell variables flowed directly into SQL INSERT statements through heredoc blocks. The addTransaction.sh script—used to automate budget entry—constructed database queries by interpolating variables like $PAYEE_UUID, $ACCOUNT_ID, $CATEGORY_ID, and $AMOUNT into raw SQL strings. While the developers recognized the risk of single-quote injection and implemented basic escaping, they missed a fundamental threat: numeric fields require no quotes to inject.

This vulnerability illustrates a common pitfall in shell-to-database bridges. Developers often assume that escaping single quotes solves SQL injection, but when variables interpolate into unquoted numeric contexts—or when attackers find encoding bypasses—the protection collapses.

The Vulnerability Explained

The vulnerable code constructed SQL by embedding shell variables directly into heredoc-delimited SQL blocks:

# Vulnerable pattern: variables interpolated into SQL
sql="INSERT INTO transactions (account, amount, payee, notes) 
     VALUES ($ACCOUNT_ID, $AMOUNT, '$PAYEE_NAME', '$NOTES');"

The original defense was minimal—single quotes were doubled through ad-hoc escaping. But this left critical gaps:

The numeric injection vector: Variables like $AMOUNT and $DATE were interpolated without surrounding quotes. An attacker could set AMOUNT="1; DELETE FROM transactions; --" and execute arbitrary SQL without needing a single quote to break out of string context.

The incomplete string protection: While $PAYEE_NAME and $NOTES had single quotes escaped, the escaping occurred inconsistently and failed to address other SQL metacharacters or encoding issues.

Consider this exploitation path through the transaction automation:

  1. A user-controlled input sets AMOUNT="0,0,0,0,0,0,0,0)--"
  2. The shell variable interpolates into: VALUES (3, 0,0,0,0,0,0,0,0)--, 5, 'Payee', 'Note')
  3. SQLite parses this as valid SQL with a commented suffix, injecting unintended values

The real-world impact extends beyond data corruption. An attacker with control over automation inputs could:
- Exfiltrate budget data through boolean-based blind SQL injection
- Modify transaction amounts to hide fraudulent transfers
- Delete or corrupt entire budget databases

The Fix

The remediation introduced a defense-in-depth strategy with two specific technical controls.

Control 1: Consistent Single-Quote Escaping

A dedicated escape_sql() function centralizes string sanitization:

# Function to escape single quotes for SQL by doubling them
escape_sql() {
    echo "$1" | sed "s/'/''/g"
}

# Escape all string values that are interpolated into SQL statements
ACCOUNT_ID=$(escape_sql "$ACCOUNT_ID")
CATEGORY_ID=$(escape_sql "$CATEGORY_ID")
PAYEE_ID=$(escape_sql "$PAYEE_ID")
TRANSFER_ACCT=$(escape_sql "$TRANSFER_ACCT")

This ensures all string-typed variables receive consistent escaping before interpolation.

Control 2: Numeric Validation

For fields that must be numeric and interpolate without quotes, strict regex validation rejects non-numeric input:

# Reject non-numeric AMOUNT/DATE since they are interpolated unquoted into SQL
if ! [[ "$AMOUNT" =~ ^-?[0-9]+$ ]]; then
    echo "ERROR: Invalid amount"
    exit 1
fi
if [ -n "$DATE" ] && ! [[ "$DATE" =~ ^-?[0-9]+$ ]]; then
    echo "ERROR: Invalid date"
    exit 1
fi

The ^-?[0-9]+$ pattern enforces:
- ^ start of string
- -? optional negative sign
- [0-9]+ one or more digits
- $ end of string

This prevents injection through numeric fields by rejecting any input containing non-digit characters, including SQL metacharacters like semicolons, dashes, and comments.

Key Takeaways

  • Heredoc SQL construction in shell scripts is inherently risky — the <<EOF pattern with variable expansion creates injection surfaces that are difficult to audit and easy to miss in code review.

  • Single-quote escaping is insufficient for SQL injection defense — numeric contexts, identifier contexts, and certain Unicode encodings can bypass quote-based escaping entirely.

  • Type validation must precede interpolation — the fix validates $AMOUNT and $DATE as integers before they reach SQL, demonstrating that input validation belongs at the trust boundary, not the database layer.

  • Shell scripts bridging to SQL need parameterized query alternatives — where SQLite's CLI doesn't support true parameterization, rigorous whitelisting and escaping must substitute.

  • Automation scripts often inherit trust boundaries poorly — scripts designed for "internal" use or automation frequently receive insufficient security scrutiny, yet they process the same untrusted inputs as production APIs.

How Orbis AppSec Detected This

Source: Shell environment variables passed to the transaction automation script, including PAYEE_UUID, NEW_PAYEE_NAME, ACCOUNT_ID, CATEGORY_ID, PAYEE_ID, NOTES, TRANSFER_UUID, AMOUNT, and DATE.

Sink: SQL INSERT statement construction via heredoc blocks with direct variable interpolation, where untrusted values reached SQLite without parameterization.

Missing control: No systematic escaping of string values and no validation that numeric fields contained only digits before unquoted interpolation.

CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

Fix: Introduced escape_sql() function for consistent single-quote doubling and regex validation (^-?[0-9]+$) to reject non-numeric values before unquoted SQL interpolation.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability in Actual Budget's transaction automation demonstrates how shell scripts—often overlooked in security assessments—can become critical SQL injection vectors when bridging user input to databases. The fix's dual approach of consistent string escaping and strict numeric validation provides a practical defense where true parameterized queries aren't available. For developers maintaining similar automation, the lesson is clear: never interpolate shell variables into SQL without both type validation and proper escaping, and treat heredoc SQL construction as a code smell requiring exceptional justification.

Prevention and further reading

Frequently Asked Questions

Why did single-quote escaping with `sed "s/'/''/g"` fail to prevent SQL injection in addTransaction.sh?

The original code only escaped single quotes for string fields, but variables like `$ACCOUNT_ID` and `$AMOUNT` were interpolated without quotes into numeric contexts, allowing injection payloads without quotes to execute.

Which specific shell variables in the transaction script were interpolated unquoted into SQL?

`$AMOUNT` and `$DATE` were interpolated directly without quotes, while `$ACCOUNT_ID`, `$CATEGORY_ID`, `$PAYEE_ID`, and `$TRANSFER_ACCT` received quote-doubling but lacked type validation.

Does the fix prevent SQL injection if an attacker controls `$NOTES` or `$NEW_PAYEE_NAME` with Unicode bypasses?

The `escape_sql()` function uses `sed` to double single quotes, which addresses standard SQL injection for quoted strings; however, the normalization and full security depends on SQLite's handling of the escaped input.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #50

Related Articles

critical

JdbcSinkFunction.invoke() SQL Injection via Unvalidated Identifiers

The `JdbcSinkFunction.invoke()` method in Lacus's RTC engine built SQL INSERT statements with `String.format()`, placing database name, table name, and column names directly into the query string without validation. Although the actual row values used parameterized `?` placeholders, the identifiers themselves were injectable, letting a crafted sink configuration break out of the intended INSERT statement. The fix adds a strict allowlist regex that rejects any identifier containing characters out

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.

critical

Toolforge Database Creation SQL Injection via Unicode Backtick Bypass

A critical SQL injection vulnerability in the database creation routine of a Node.js wiki library allowed attackers to inject arbitrary SQL by exploiting a weak validation check. The fix replaces a character-based block with a strict allowlist pattern, ensuring only valid database identifiers reach the SQL engine.

critical

heatmap.php SQL Injection: $_REQUEST Parameters in Unparameterized

A critical SQL injection vulnerability in the heatmap data retrieval endpoint allowed attackers to execute arbitrary database commands by manipulating coordinate bounds or time range parameters. The vulnerability affected all six user-controlled $_REQUEST parameters passed directly into query construction without parameterization.

critical

SQL's Insert() and Update() Methods Used F-String Interpolation in u2share_batch_give_sugar

The SQL helper class in u2share_batch_give_sugar used Python f-strings to construct INSERT and UPDATE queries, creating SQL injection vulnerabilities even though values appeared to come from internal constants. The fix replaces all f-string query construction with sqlite3 parameterized queries using `?` placeholders, eliminating string interpolation entirely from the database path.

critical

redmine_drawio View Hook Inlines Base64 Redmine API Keys

The Redmine drawio plugin's body-bottom view listener embedded the logged-in user's Redmine REST API key into client-side JavaScript on every wiki and issue page, "protected" only by Base64 encoding of the reversed string. Any script on the page — or anyone with browser developer tools, a cached copy of the HTML, or a proxy log — could decode it in one line and act as that user through the Redmine REST API. The fix removes the embedded credential from the rendered page entirely; the plugin's dia