When the Installer Is the Attack Surface
Most SQL injection write-ups start at an HTTP endpoint. This one starts in the script that runs once, at container build or first boot, to create the very first administrator.
The script generates a random password and derives a salted hash, $ADMIN_HASH. It then hands that hash to the MySQL command-line client by splicing it straight into a SQL string:
mysql -e "insert into jol.users (user_id,password,nick,reg_time) values('admin','$ADMIN_HASH','admin',now());"
The bash double quotes expand $ADMIN_HASH before mysql ever sees the text. Whatever bytes are in that variable become part of the SQL grammar, so a single quote in the value ends the string literal early.
The next line in the same script grants administrator rights through jol.privilege. That makes this insert the most privilege-sensitive statement the application ever runs.
The PR author is candid that this is hardening rather than a proven exploit. It is still worth studying, because "trusted" shell-to-SQL glue like this is copied between projects constantly.
Affected Versions
| Affected | not applicable (first-party code); the admin-seeding mysql -e insert in the container setup script prior to this change |
| Fixed in | not applicable (first-party code); fixed by the commit that introduces ADMIN_HASH_HEX and unhex() |
| Ecosystem | not applicable (shell script / MySQL) |
| CVE / GHSA | not assigned |
| CWE | CWE-89: Improper Neutralization of Special Elements used in an SQL Command (SQL Injection) |
The Vulnerability Explained
The vulnerable code
ADMIN_PASSWORD=`openssl rand -base64 12`
ADMIN_SALT=`openssl rand -hex 2`
ADMIN_MD5=`printf '%s' "$ADMIN_PASSWORD" | openssl md5 -r | cut -d' ' -f1`
ADMIN_HASH=`{ printf '%s%s' "$ADMIN_MD5" "$ADMIN_SALT" | openssl sha1 -binary; printf '%s' "$ADMIN_SALT"; } | openssl base64 -A`
mysql -e "insert into jol.users (user_id,password,nick,reg_time) values('admin','$ADMIN_HASH','admin',now());"
The problem is in the last line, specifically the fragment '$ADMIN_HASH'. Two quoting layers are stacked here:
- Bash double quotes around the whole
-eargument, which perform variable expansion. - SQL single quotes around the hash, which MySQL treats as a string literal.
Nothing sits between those layers to escape ', \, or ;. Unlike the separate transaction scripts flagged by the same scanner rule, this line does not even double up single quotes. The mysql client has no ? placeholder syntax for -e, so true parameterization is not available at this call site.
Is it exploitable today?
As currently written, the value is the output of openssl base64 -A. Its alphabet is A–Z a–z 0–9 + / =, so a quote cannot appear and the insert is safe by accident.
That safety depends on three assumptions that hold today but are not enforced anywhere:
- The pipeline stays the same. Someone might switch the encoding to raw binary (
openssl sha1 -binarywithout the base64 step) or to a different hash format such as bcrypt. ADMIN_HASHis never pre-seeded. A future refactor could let the variable be supplied from the environment, a.envfile, or a build argument so operators can choose the admin password.- The build environment is honest. In a CI pipeline, whoever controls build args or environment variables could control this value.
What an injection would look like
Suppose ADMIN_HASH becomes externally settable. The mysql client splits -e input on semicolons and runs each statement, so a value like this one:
x','admin',now()),('mallory','x','m',now());insert into jol.privilege(user_id,rightstr) values('mallory','administrator');--
would close the admin row and add a second user, mallory. It would then run an extra insert into jol.privilege that grants that user administrator. The trailing -- comments out the original ','admin',now()); remainder.
The result is a backdoor administrator baked into a fresh installation. Because the seeding script runs as a MySQL user with full rights on the jol schema, any statement is possible, including DROP, UPDATE, or GRANT.
Real-world impact
The prerequisite is control over the setup environment, which is much narrower than a remote endpoint. The payoff is a persistent, high-privilege foothold that survives until someone audits the privilege table.
The Fix
The change keeps the mysql -e call but guarantees that the SQL literal can only contain hexadecimal digits:
-mysql -e "insert into jol.users (user_id,password,nick,reg_time) values('admin','$ADMIN_HASH','admin',now());"
+ADMIN_HASH_HEX=`printf '%s' "$ADMIN_HASH" | xxd -p | tr -d '\n'`
+mysql -e "insert into jol.users (user_id,password,nick,reg_time) values('admin',unhex('$ADMIN_HASH_HEX'),'admin',now());"
Each piece has a specific job:
printf '%s' "$ADMIN_HASH"emits the value without a trailing newline and without interpreting backslash escapes, whichechomight do.xxd -pconverts every byte to two characters from[0-9a-f]. The output alphabet is now closed: no quote, semicolon, backslash, or comment marker can survive.tr -d '\n'strips the line breaks thatxxd -pinserts every 60 hex characters. Without it, the hex string would be split across lines inside the SQL literal.unhex('...')reverses the encoding inside MySQL, so the storedpasswordcolumn receives exactly the original bytes.
This is the shell equivalent of a bound parameter. The data still travels inside the SQL text, but in a form whose character set cannot change the statement's structure. The stored value is unchanged, so the application's login verification keeps working.
A caveat introduced by the fix
The fix adds a dependency on xxd, which in many distributions ships with vim-common rather than coreutils. If the image lacks it, two things go wrong:
ADMIN_HASH_HEXsilently becomes an empty string.- The insert stores
unhex(''), an empty password hash.
Worth adding alongside this change:
[ -n "$ADMIN_HASH_HEX" ] || { echo "hex encoding failed" >&2; exit 1; }
Alternatively, use od -An -tx1 | tr -d ' \n', which is available in nearly every base image.
Key Takeaways
mysql -e "...'$VAR'..."gives$VARthe power to write SQL. Bash expands the variable before the client parses anything, and-eexecutes every semicolon-separated statement.- An alphabet that is safe by accident is not a control.
$ADMIN_HASHis only quote-free becauseopenssl base64 -Ahappens to be last in the pipeline. Encoding the value as hex makes that property explicit and permanent. - Hex plus
unhex()(or aX'...'literal) is the practical "parameterized query" for themysqlCLI. The client offers no placeholders for-e, so restrict the character set instead of escaping. - Seeding scripts that write to
jol.privilegedeserve the same scrutiny as login handlers. An injected row there is a persistent administrator. - Hardening fixes can add new failure modes. Here, a missing
xxdsilently produces an empty admin hash unless the script checks the result.
How Orbis AppSec Detected This
- Source: the shell variable
ADMIN_HASH, built from command substitution and potentially overridable by the script's execution environment. - Sink: the
mysql -ecommand-line invocation that executes aninsert into jol.usersstatement built by bash string expansion. - Missing control: no escaping, encoding, or character-set restriction was applied to
$ADMIN_HASHbefore embedding it inside a single-quoted SQL literal. - CWE: CWE-89, Improper Neutralization of Special Elements used in an SQL Command (SQL Injection).
- Fix: the hash is hex-encoded with
xxd -pand decoded in MySQL withunhex(), so only[0-9a-f]characters reach the SQL parser.
Orbis AppSec detected this vulnerability automatically. Try Orbis AppSec on your repositories to find and fix issues like this.
Conclusion
The admin-seeding insert was one environment variable away from letting a build pipeline mint its own administrator. It was safe only because openssl base64 -A happened to emit a quote-free alphabet.
Replacing '$ADMIN_HASH' with unhex('$ADMIN_HASH_HEX') turns that coincidence into a guarantee, without changing what the application stores or how it verifies logins. Pair the change with a non-empty check on ADMIN_HASH_HEX, or an xxd-free encoder.
Then review every other shell script in the codebase that builds SQL through heredocs or mysql -e, since they share the same pattern and the same blind spot.