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
Published September 18, 2026Reviewed 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

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.

high

TrackOptionsManager DDL: Template-Literal SQL Injection Closed

The `TrackOptionsManager` service built its `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN alias` statements by interpolating a `DEFAULT_ALIAS` constant directly inside single quotes in a JavaScript template literal, and its private `_query()` helper had no parameter channel at all. The fix routes the default value through `mysql.escape()` and gives `_query(q, params = [])` a real bound-parameter argument that is forwarded to `db.query()`. This removes an injection primitive on a schema-bootstra

high

How SQL injection via template literals happens in Node.js SQLite and how to fix it

A SQL injection vulnerability in `src/lib/codex-state.mjs` allowed dynamic column names to reach SQL queries through JavaScript template literals. The fix implements defense-in-depth with strict identifier validation using `SAFE_IDENTIFIER` regex before query construction.

high

How Python SQLAlchemy Raw Query SQL Injection happens and how to fix it

A high-severity SQL injection vulnerability was fixed in the `skills/last30days/scripts/store.py` file where untrusted input was being concatenated directly into raw SQL queries. The fix replaces string concatenation with SQLAlchemy's TextualSQL prepared statements using named parameters, preventing attackers from manipulating database queries through malicious input.

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

critical

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke