Seguridad · RustSecurity · Rust

Un secreto que ejecutaba código: postmortem de una vulnerabilidad en mi gestor de secretosA secret that ran code: post-mortem of a vulnerability in my own secrets manager

En esta páginaOn this page
  1. Una línea que parecía inofensiva
  2. Vaultic en dos minutos
  3. El fallo: datos que se convierten en código
  4. ¿Quién podía explotarlo?
  5. El arreglo: comillas simples
  6. Por qué '\''
  7. El segundo vector: saltos de línea en $GITHUB_ENV
  8. Los fallos pequeños que vinieron detrás
  9. 1. Secretos que la máscara no tapaba
  10. 2. Claves privadas legibles por otros usuarios
  11. 3. Un hash que confirmaba contraseñas
  12. 4. Archivos a medias
  13. Publicarlo
  14. Lo que me llevo
  15. Si usas Vaultic
  1. A line that looked harmless
  2. Vaultic in two minutes
  3. The bug: data that turns into code
  4. Who could exploit it?
  5. The fix: single quotes
  6. Why '\''
  7. The second vector: newlines in $GITHUB_ENV
  8. The smaller bugs behind it
  9. 1. Secrets the mask didn't hide
  10. 2. Private keys readable by other users
  11. 3. A hash that confirmed passwords
  12. 4. Half-written files
  13. Disclosing it
  14. What I take away
  15. If you use Vaultic

Una línea que parecía inofensiva#

Acabo de publicar un aviso de seguridad de gravedad alta, con una puntuación CVSS de 8,7, contra una herramienta mía: Vaultic, un gestor de secretos para equipos escrito en Rust. Todo el fallo cabía en estas líneas de la versión 1.4.2:

src/cli/commands/ci.rs (1.4.2)
"github" => {
    if mask {
        println!("echo \"::add-mask::{value}\"");
    }
    println!("echo \"{key}={value}\" >> \"$GITHUB_ENV\"");
}
"gitlab" => {
    println!("export {key}=\"{value}\"");
}

Parecen inofensivas: imprimen una variable de entorno por línea, con su valor entre comillas. De hecho, es la misma sintaxis que enseña la documentación de GitHub para definir variables en un workflow. Y la documentación de Vaultic decía que se usaran así:

Shell
eval "$(vaultic ci export --env prod --format github)"

Si ya ves el problema, vas por delante. Si no, esta entrada es para ti: lo reproducimos con los binarios reales, vemos quién podía explotarlo y cómo se arregló, repasamos los fallos más pequeños que aparecieron detrás y contamos cómo se publicó.

Vaultic en dos minutos#

Vaultic nace de algo que he visto en casi todos los equipos en los que he trabajado: archivos .env con contraseñas y claves de API que se pasan por chat o por correo. En su lugar, Vaultic los cifra y los guarda en el propio repositorio, junto al código, y viajan con Git como cualquier otro archivo. Lo usamos en nuestro equipo en proyectos reales, con varios entornos y varias personas.

El cifrado es asimétrico y usa age. Cada persona tiene su par de claves, y .vaultic/recipients.txt guarda las claves públicas de todo el equipo. Al cifrar, el contenido se cifra una sola vez a partir de una clave de archivo aleatoria (con ChaCha20-Poly1305), y esa clave de archivo se cifra por separado para cada destinatario (con X25519). Esta es la cabecera real de un .env.enc de pruebas con dos destinatarios, una vez decodificada:

salidaoutput
age-encryption.org/v1
-> X25519 NbUtRsMs0VKh++9Kv//IWVvFeUJ5iTlGho2sqk16FDk
bAFsWhFNKwB4LDBTyNokqs9OmctUG0Jumi+knI44RDA
-> X25519 kkSyhB9UFk+741ADe+2W9hjdtWb28EFV07CU70M8LS8
TwSKydQdVI+dESQD4MLWYVeE9YlxlNlJYS7I/KhXQlU
-> g1z8y-grease
XbknbG4IWhrYPriLmcTaFVwMvfxf+QUsHqmJ7ChzBu5L+bpnM8TXMYlrmjjZAQg8
fjmtUZv7FXVkhf9Rrd4DeWp8ke7o+PzF00vH2IXW
--- XKQxc+r/6LZzS3RE…

Cada bloque -> X25519 es la clave de archivo cifrada para una persona: quien descifra busca la suya y la abre con su clave privada. El tercero, grease, es relleno aleatorio que la biblioteca de age añade a propósito, para que los programas que leen el formato se acostumbren a ignorar tipos de destinatario que no conocen. Después de la línea --- empieza el contenido cifrado.

De ahí sale una propiedad que importa para el resto de la historia: las claves públicas no son secretas. Con una clave pública solo se puede cifrar, nunca descifrar, así que tenerlas en el repositorio es seguro. Es como la ranura de un buzón: cualquiera puede echar una carta, pero solo quien tiene la llave puede leerla. Quédate con eso: cualquiera puede echar una carta.

El fallo: datos que se convierten en código#

vaultic ci export descifra un entorno dentro de un pipeline de CI y lo imprime como comandos de shell, para que eval los ejecute y las variables queden definidas. El problema es que dentro de comillas dobles el shell sigue interpretando cosas: $VARIABLE, $(comando), las comillas invertidas y la barra invertida. El valor de un secreto dejaba de ser un dato y pasaba a ser código.

Para comprobarlo, compilé la 1.4.2 y la 1.4.3 desde sus etiquetas en el repositorio y cifré un entorno de pruebas con estos dos valores:

.env
DB_PASSWORD=Kx9$$fT2w
API_TOKEN=tok_$(echo "  [!] esto se ha ejecutado" >&2)

Esto es lo que imprimía la 1.4.2 con --format gitlab:

Shell
export DB_PASSWORD="Kx9$$fT2w"
export API_TOKEN="tok_$(echo "  [!] esto se ha ejecutado" >&2)"

Y este script carga los secretos como decía la documentación, primero con una versión y después con la otra:

cargar.sh
#!/bin/sh
# sh cargar.sh   (con vaultic-1.4.2 y vaultic-1.4.3 en la misma carpeta)
# Carga los secretos como decía la documentación, con cada versión
for v in 1.4.2 1.4.3; do
  echo "--- vaultic $v"
  eval "$(./vaultic-$v -q ci export --env dev --format gitlab)"
  echo "DB_PASSWORD=$DB_PASSWORD"
  echo "API_TOKEN=$API_TOKEN"
done

Esta es la salida real en mi equipo, con el sh de Git Bash en Windows 11:

salidaoutput
--- vaultic 1.4.2
  [!] esto se ha ejecutado
DB_PASSWORD=Kx922537fT2w
API_TOKEN=tok_
--- vaultic 1.4.3
DB_PASSWORD=Kx9$$fT2w
API_TOKEN=tok_$(echo "  [!] esto se ha ejecutado" >&2)

Con la 1.4.2 pasan dos cosas, y las dos son malas:

  • El valor de API_TOKEN se ejecuta. Aquí solo imprime un aviso, pero podría ser cualquier comando, y correría en el runner con los permisos del job, con acceso a todos sus secretos y tokens.
  • La contraseña se corrompe sin que nadie ataque nada. $$ es una variable especial del shell, el PID del proceso, así que Kx9$$fT2w se convierte en Kx922537fT2w. Y las contraseñas generadas llevan $, comillas y barras constantemente.

En mis pruebas apareció un tercer caso: con una comilla invertida suelta en una contraseña, el eval entero falla con un error de sintaxis y no se carga ningún secreto del entorno.

Con la 1.4.3, los valores llegan intactos, contengan lo que contengan.

Vaultic está escrito en Rust y, como contamos en Por qué Rust sigue siendo el lenguaje más seguro en 2026, Rust elimina clases enteras de fallos de memoria. Pero este no es un fallo de memoria, sino de significado: ningún compilador sabe que un String va a acabar dentro de un shell.

¿Quién podía explotarlo?#

Aquí vuelve el buzón. Para meter un valor malicioso en un .env.enc no hace falta ninguna clave privada: bastan las claves públicas, que están en el repositorio. Así que cualquiera que pudiera llevar un .vaultic/*.env.enc modificado a una rama cuyo job de CI ejecute vaultic ci export con la clave privada (en la variable VAULTIC_AGE_KEY) podía ejecutar código en ese job.

Y hay un agravante: un archivo cifrado no se puede revisar. En un pull request, un cambio en dev.env.enc es un bloque de base64 que cambia entero. Quien revisa ve que se han actualizado los secretos, no un $(...) escondido dentro.

1 CIFRA cualquiera cifra un $(...) con las claves públicas 2 PR dev.env.enc cambia: base64 que nadie puede revisar 3 CI vaultic ci export descifra con VAULTIC_AGE_KEY 4 EVAL el valor se ejecuta con los permisos del job 1.4.3 comillas simples: el valor llega como texto, no como código
Fig. 1 — El camino del ataque. No hace falta ninguna clave privada, y el paso 2 es invisible en la revisión. La 1.4.3 corta la cadena en el paso 4.
En una frase

Quien pudiera cambiar un archivo cifrado en una rama con CI podía ejecutar código en ese CI, sin tener ninguna clave privada y sin que la revisión del pull request lo mostrara.

El vector CVSS del aviso lo resume así:

salidaoutput
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N

Métrica a métrica:

Métrica Valor Por qué
Vector de ataque Red (AV:N) Basta con empujar un cambio a un repositorio remoto
Complejidad Baja (AC:L) Un $(...) en un valor, sin condiciones especiales
Privilegios Altos (PR:H) Hay que poder modificar archivos de una rama que ejecute el CI con la clave
Interacción Ninguna (UI:N) El pipeline se ejecuta solo
Alcance Cambia (S:C) El fallo está en Vaultic, pero el daño lo sufre el runner de CI y todo lo que alcanzan sus credenciales
Impacto C:H/I:H/A:N Confidencialidad e integridad totales dentro del job; la disponibilidad no es el objetivo

PR:H es lo que impide que llegue a crítico: no lo puede explotar cualquiera desde fuera. S:C es lo que lo sube tanto: quien ejecuta el código ya no es Vaultic, es tu infraestructura de CI.

El arreglo: comillas simples#

Dentro de comillas simples, un shell POSIX no expande nada: ni variables, ni comandos, ni barras invertidas. El único carácter que hay que tratar es la propia comilla simple, que se cierra, se escapa y se vuelve a abrir. Esta es la función entera del arreglo:

src/cli/commands/ci.rs (1.4.3)
/// Quote `s` for POSIX shells. Inside single quotes nothing is expanded,
/// so the only character to handle is the single quote itself.
fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

Con ella, --format gitlab imprime export API_TOKEN='tok_$(...)', y --format github pasa a usar printf en lugar de echo:

Shell
printf '%s\n' 'API_KEY=secret123' >> "$GITHUB_ENV"

printf '%s\n' imprime su argumento tal cual. echo no es tan predecible: según el shell, interpreta las barras invertidas o trata un -n como una opción.

Por qué '\''#

La parte más críptica del arreglo es cambiar cada ' por '\''. Dentro de comillas simples no se puede escapar nada, ni siquiera otra comilla simple, así que el truco consiste en salir de ellas un instante. Un valor como it's se convierte en 'it'\''s', que el shell lee como tres trozos pegados:

  1. 'it': texto entre comillas simples.
  2. \': una comilla simple escapada, fuera de las comillas.
  3. 's': más texto entre comillas simples.

El shell une las palabras que van pegadas sin espacios, así que el resultado es exactamente it's. Y en ningún momento queda texto fuera de las comillas, salvo esa comilla escapada, que no puede hacer nada. El test shell_quote_escapes_single_quotes del arreglo comprueba este caso con a'b.

Además, con github y gitlab los nombres de variable tienen que ser identificadores de shell válidos; si no, la exportación falla antes de imprimir nada. Y lo que más me gusta del arreglo son sus tests: no comparan cadenas, sino que ejecutan de verdad el script generado con sh y comprueban que no se ha ejecutado nada:

Rust
#[cfg(unix)]
#[test]
fn github_does_not_execute_command_substitution() {
    let value = "p@$(echo PWNED)`echo PWNED2`\"'\\";
    let (env, stdout) = run_github_script(&format_github("PASS", value, false));
    assert_eq!(env, format!("PASS={value}\n"));
    assert!(!stdout.contains("PWNED"));
}
La regla

Si un dato acaba dentro de algo que se interpreta, sea SQL, HTML o un shell, necesita el escapado exacto de ese lenguaje. Y si puedes, no generes código: pasa el dato por un canal que nunca se interprete.

El segundo vector: saltos de línea en $GITHUB_ENV#

$GITHUB_ENV es un archivo que el runner de GitHub lee línea a línea: cada NOMBRE=valor define una variable para los pasos siguientes del job. Si un valor contuviera un salto de línea, lo que va detrás se leería como otra variable. Y hay variables peligrosas por sí mismas: LD_PRELOAD hace que cada proceso cargue una biblioteca, y BASH_ENV, que cada script de Bash ejecute antes un archivo. (NODE_OPTIONS sería otra, pero el runner de GitHub ya la rechaza en $GITHUB_ENV.)

Hoy el parser de .env de Vaultic lee línea a línea, así que un valor no puede traer un salto de línea real. Pero el formato de salida no debe depender de eso: el roadmap incluye parsers de YAML y JSON, que sí admiten valores de varias líneas. Para esos valores, GitHub define una sintaxis propia, NOMBRE<<DELIMITADOR, y su documentación avisa de que el delimitador no debe aparecer como línea dentro del valor. La 1.4.3 lo garantiza por construcción:

src/cli/commands/ci.rs (1.4.3)
fn github_delimiter(value: &str) -> String {
    use sha2::{Digest, Sha256};
    let hash = format!("{:x}", Sha256::digest(value.as_bytes()));
    let mut delim = format!("VAULTIC_EOF_{}", &hash[..16]);
    while value.lines().any(|l| l == delim) {
        delim.push('_');
    }
    delim
}

El delimitador sale del hash del propio valor y, si aun así apareciera como línea, se alarga hasta que deje de aparecer. Un valor no puede cerrar el bloque antes de tiempo.

Los fallos pequeños que vinieron detrás#

Un fallo de inyección rara vez viaja solo. Al revisar el resto de caminos por los que pasan los secretos aparecieron cuatro más, de menor gravedad, que también arregla la 1.4.3.

1. Secretos que la máscara no tapaba#

--mask emite ::add-mask::valor, que pide al runner de GitHub que tape ese valor en los logs. Pero el runner decodifica %25, %0D y %0A en los datos de sus comandos, como se ve en su ActionCommand.cs. Un secreto que contuviera literalmente %0A se registraba con otro valor, y el original salía en claro en el log. El arreglo escapa % como %25 antes de enviarlo.

El mismo runner tiene otro detalle: lee la salida de cada paso con ReadLine de .NET, que también corta en un \r suelto. Un secreto con un \r dentro llegaba al log partido en dos líneas, y la máscara del valor completo no coincidía con ninguna. Ahora se enmascara cada trozo por separado.

2. Claves privadas legibles por otros usuarios#

Las claves privadas se escribían con los permisos por defecto del umask, normalmente 0644: legibles por cualquier usuario de la máquina. Ahora se crean con 0600, dentro de un directorio 0700, igual que los .env descifrados. Y vaultic status avisa si los permisos de tu clave son demasiado abiertos, con la misma comprobación que aplica ssh.

3. Un hash que confirmaba contraseñas#

El registro de auditoría, audit.log, se commitea con el repositorio y no guarda valores, solo metadatos. Pero al descifrar, la 1.4.2 guardaba el SHA-256 del archivo descifrado. Un hash no se puede invertir, pero sí se puede comprobar: si adivinas el contenido, calculas su hash y lo comparas. Y el contenido de un .env es muy adivinable, porque los nombres de las variables están en .env.template y muchos valores son predecibles. Esta es la entrada real que dejó la 1.4.2 en mi entorno de pruebas, con una identidad de git de pruebas, sin la fecha y formateada para leerla mejor:

JSON
{
  "author": "Ana",
  "email": "ana@example.com",
  "action": "decrypt",
  "files": ["prod.env.enc"],
  "detail": "2 variables decrypted to .env",
  "state_hash": "aed281bc6ec061b2e862e9dae6ba5a4726bc22a28b3b153b154f1e997aa4ca22"
}

Y este script, que solo usa ese hash, confirma la contraseña sin descifrar nada:

adivinar.sh
#!/bin/sh
# sh adivinar.sh   (en la raíz del proyecto, tras descifrar con la 1.4.2)
# El hash que la 1.4.2 dejaba en audit.log al descifrar (se commitea)
target=$(tail -1 .vaultic/audit.log | sed 's/.*"state_hash":"\([0-9a-f]*\)".*/\1/')
# Las claves salen de .env.template; solo falta adivinar la contraseña
for guess in 123456 password invierno2024 verano2024 qwerty; do
  h=$(printf 'PORT=5432\nDB_PASSWORD=%s\n' "$guess" | sha256sum | cut -d' ' -f1)
  [ "$h" = "$target" ] && echo "confirmada: DB_PASSWORD=$guess" && exit
done
echo "ninguna coincide"

Y su salida real:

salidaoutput
confirmada: DB_PASSWORD=verano2024

Con una contraseña débil basta un diccionario; con una fuerte y aleatoria no hay nada que hacer. Pero una herramienta de secretos no debería regalar un oráculo para comprobar suposiciones. La 1.4.3 guarda el hash del archivo cifrado, que sigue sirviendo para detectar manipulaciones y no dice nada del contenido.

4. Archivos a medias#

Si encrypt --all se interrumpía a mitad de escribir un .enc, el archivo podía quedar truncado. Ahora todo se escribe de forma atómica: primero en un archivo temporal del mismo directorio, que se sincroniza con el disco y después se renombra sobre el original. Un corte deja el archivo viejo o el nuevo, nunca uno a medias.

Publicarlo#

Arreglarlo no basta: quien use una versión vulnerable tiene que enterarse. Estos fueron los pasos:

  1. La versión 1.4.3, con los binarios verificables con SHA256 y firma minisign, publicada también en crates.io.
  2. Un aviso de seguridad en GitHub, GHSA-5cfx-fmm5-7p2f, con el impacto, las versiones afectadas (de la 1.4.0 a la 1.4.2; la 1.3.0 aún no tenía ci export), el arreglo y alternativas para quien no pueda actualizar todavía. Cuando GitHub lo revise, pasará a su base de datos pública de avisos, de la que beben herramientas como Dependabot.
  3. Una CVE, solicitada a GitHub, que es autoridad de numeración de CVE. Está en revisión; añadiré aquí el número cuando la asignen.
  4. RustSec, la base de datos de avisos del ecosistema Rust que consulta cargo audit. Preparé el aviso en su formato y lo validé con su propio linter antes de enviarlo, pero un mantenedor cerró el PR: Vaultic no llega a su mínimo de popularidad, con 29 descargas recientes. El aviso era correcto; la regla es suya, y tiene sentido en una base de datos que mantienen voluntarios. La consecuencia práctica es que cargo audit no avisará de este fallo, así que los canales que cuentan son el aviso de GitHub y las notas de la versión.

La misma versión actualiza rustls, que usa la comprobación de actualizaciones, por cinco avisos de RustSec. Y el repositorio ejecuta ahora cargo audit cada semana y cada vez que cambian las dependencias.

Lo que me llevo#

  • Nunca metas datos dentro de código que otro va a ejecutar. Si un valor acaba dentro de algo que se interpreta, necesita el escapado exacto de ese lenguaje. Para un shell POSIX, comillas simples.
  • Documentar eval es una promesa fuerte. Si tu herramienta genera código para que el usuario lo ejecute, esa salida es superficie de ataque y merece tests que la ejecuten de verdad.
  • Lo cifrado no se revisa solo. Trata los cambios en .vaultic/ como cambios de código: exige revisión, por ejemplo con CODEOWNERS, y que alguien con clave los descifre y compare antes de aprobar. Quien controla los secretos de un job controla su entorno.
  • Quitar una clave no borra el pasado. Cuando alguien deja el equipo, vaultic keys remove y encrypt --all protegen las versiones nuevas, pero las antiguas siguen en el historial de Git, cifradas también para su clave. Hay que rotar los valores: contraseñas, tokens y claves de API.
  • El hash de un secreto es un oráculo. Si necesitas detectar cambios, calcula el hash del texto cifrado, o usa un HMAC con una clave que no esté en el repositorio.
  • Publica aunque tu proyecto sea pequeño. El aviso público es lo que permite a cada usuario saber si le afecta y qué hacer.
Nota personal

Vaultic es un proyecto pequeño: a día de hoy tiene 145 descargas en crates.io. Aun así, el fallo era real, con un camino claro hasta ejecutar código en un CI. Y no estaba en la criptografía, que es la parte que más cuidado recibe, sino en una línea que formateaba texto. En cualquier herramienta que genere scripts, ese es el primer sitio donde mirar: el punto exacto en el que un dato se convierte en código.

Si usas Vaultic#

Actualiza:

Shell
vaultic update            # o: cargo install vaultic --force

Si todavía no puedes, no uses eval con --format github ni con --format gitlab. Usa vaultic ci export --format generic > .env o vaultic decrypt --env <entorno> --stdout con un cargador de .env que no ejecute código, y revisa los cambios recientes en .vaultic/*.env.enc hechos por personas que no deberían controlar tu CI.

A line that looked harmless#

I've just published a high-severity security advisory, with a CVSS score of 8.7, against a tool of my own: Vaultic, a team secrets manager written in Rust. The whole bug fit in these lines of version 1.4.2:

src/cli/commands/ci.rs (1.4.2)
"github" => {
    if mask {
        println!("echo \"::add-mask::{value}\"");
    }
    println!("echo \"{key}={value}\" >> \"$GITHUB_ENV\"");
}
"gitlab" => {
    println!("export {key}=\"{value}\"");
}

They look harmless: they print one environment variable per line, with its value in quotes. In fact, it's the same syntax GitHub's documentation shows for setting variables in a workflow. And Vaultic's documentation said to use them like this:

Shell
eval "$(vaultic ci export --env prod --format github)"

If you already see the problem, you're ahead. If not, this post is for you: we reproduce it with the real binaries, look at who could exploit it and how it was fixed, go through the smaller bugs that turned up behind it, and tell how it was disclosed.

Vaultic in two minutes#

Vaultic comes from something I've seen in almost every team I've worked with: .env files with passwords and API keys passed around over chat or email. Instead, Vaultic encrypts them and keeps them in the repository itself, next to the code, and they travel with Git like any other file. We use it in our team on real projects, with several environments and several people.

The encryption is asymmetric and uses age. Each person has a key pair, and .vaultic/recipients.txt holds the whole team's public keys. On encryption, the content is encrypted once from a random file key (with ChaCha20-Poly1305), and that file key is encrypted separately for each recipient (with X25519). This is the real header of a test .env.enc with two recipients, decoded:

salidaoutput
age-encryption.org/v1
-> X25519 NbUtRsMs0VKh++9Kv//IWVvFeUJ5iTlGho2sqk16FDk
bAFsWhFNKwB4LDBTyNokqs9OmctUG0Jumi+knI44RDA
-> X25519 kkSyhB9UFk+741ADe+2W9hjdtWb28EFV07CU70M8LS8
TwSKydQdVI+dESQD4MLWYVeE9YlxlNlJYS7I/KhXQlU
-> g1z8y-grease
XbknbG4IWhrYPriLmcTaFVwMvfxf+QUsHqmJ7ChzBu5L+bpnM8TXMYlrmjjZAQg8
fjmtUZv7FXVkhf9Rrd4DeWp8ke7o+PzF00vH2IXW
--- XKQxc+r/6LZzS3RE…

Each -> X25519 block is the file key encrypted for one person: whoever decrypts finds theirs and opens it with their private key. The third one, grease, is random padding the age library adds on purpose, so that programs reading the format get used to ignoring recipient types they don't know. The encrypted content starts after the --- line.

This gives a property that matters for the rest of the story: public keys aren't secret. A public key can only encrypt, never decrypt, so keeping them in the repository is safe. It's like a mailbox slot: anyone can drop a letter in, but only whoever has the key can read it. Keep that in mind: anyone can drop a letter in.

The bug: data that turns into code#

vaultic ci export decrypts an environment inside a CI pipeline and prints it as shell commands, so that eval runs them and the variables get set. The problem is that inside double quotes the shell still interprets things: $VARIABLE, $(command), backticks and backslashes. A secret's value stopped being data and became code.

To check it, I built 1.4.2 and 1.4.3 from their tags in the repository and encrypted a test environment with these two values:

.env
DB_PASSWORD=Kx9$$fT2w
API_TOKEN=tok_$(echo "  [!] this just ran" >&2)

This is what 1.4.2 printed with --format gitlab:

Shell
export DB_PASSWORD="Kx9$$fT2w"
export API_TOKEN="tok_$(echo "  [!] this just ran" >&2)"

And this script loads the secrets the way the docs said, first with one version and then with the other:

load.sh
#!/bin/sh
# sh load.sh   (with vaultic-1.4.2 and vaultic-1.4.3 in the same folder)
# Load the secrets the way the docs said, with each version
for v in 1.4.2 1.4.3; do
  echo "--- vaultic $v"
  eval "$(./vaultic-$v -q ci export --env dev --format gitlab)"
  echo "DB_PASSWORD=$DB_PASSWORD"
  echo "API_TOKEN=$API_TOKEN"
done

This is the real output on my machine, with Git Bash's sh on Windows 11:

salidaoutput
--- vaultic 1.4.2
  [!] this just ran
DB_PASSWORD=Kx922542fT2w
API_TOKEN=tok_
--- vaultic 1.4.3
DB_PASSWORD=Kx9$$fT2w
API_TOKEN=tok_$(echo "  [!] this just ran" >&2)

With 1.4.2 two things happen, and both are bad:

  • The value of API_TOKEN runs. Here it only prints a warning, but it could be any command, and it would run on the runner with the job's permissions, with access to all its secrets and tokens.
  • The password gets corrupted with nobody attacking anything. $$ is a special shell variable, the process PID, so Kx9$$fT2w becomes Kx922542fT2w. And generated passwords contain $, quotes and backslashes all the time.

A third case came up in my tests: with a stray backtick in a password, the whole eval fails with a syntax error and none of the environment's secrets get loaded.

With 1.4.3, the values arrive intact, whatever they contain.

Vaultic is written in Rust and, as we covered in Why Rust remains the safest language in 2026, Rust removes whole classes of memory bugs. But this isn't a memory bug, it's a bug of meaning: no compiler knows a String is going to end up inside a shell.

Who could exploit it?#

Here's where the mailbox comes back. Putting a malicious value into a .env.enc needs no private key at all: the public keys are enough, and they're in the repository. So anyone who could get a modified .vaultic/*.env.enc into a branch whose CI job runs vaultic ci export with the private key (in the VAULTIC_AGE_KEY variable) could run code in that job.

And there's an aggravating factor: an encrypted file can't be reviewed. In a pull request, a change to dev.env.enc is a block of base64 that changes entirely. The reviewer sees that the secrets were updated, not a $(...) hidden inside.

1 ENCRYPT anyone encrypts a $(...) with the public keys 2 PR dev.env.enc changes: base64 nobody can review 3 CI vaultic ci export decrypts with VAULTIC_AGE_KEY 4 EVAL the value runs with the job's permissions 1.4.3 single quotes: the value arrives as text, not as code
Fig. 1 — The attack path. No private key is needed, and step 2 is invisible in review. Version 1.4.3 breaks the chain at step 4.
In one sentence

Anyone who could change an encrypted file in a branch with CI could run code in that CI, without holding any private key and without the pull request review showing it.

The advisory's CVSS vector sums it up:

salidaoutput
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N

Metric by metric:

Metric Value Why
Attack vector Network (AV:N) Pushing a change to a remote repository is enough
Complexity Low (AC:L) A $(...) in a value, no special conditions
Privileges High (PR:H) You need to be able to modify files in a branch that runs CI with the key
Interaction None (UI:N) The pipeline runs on its own
Scope Changed (S:C) The bug is in Vaultic, but the damage lands on the CI runner and everything its credentials reach
Impact C:H/I:H/A:N Full confidentiality and integrity inside the job; availability isn't the goal

PR:H is what keeps it from being critical: not just anyone can exploit it from outside. S:C is what pushes it so high: the one running the code is no longer Vaultic, it's your CI infrastructure.

The fix: single quotes#

Inside single quotes, a POSIX shell expands nothing: no variables, no commands, no backslashes. The only character to handle is the single quote itself, which is closed, escaped and reopened. This is the fix's entire function:

src/cli/commands/ci.rs (1.4.3)
/// Quote `s` for POSIX shells. Inside single quotes nothing is expanded,
/// so the only character to handle is the single quote itself.
fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}

With it, --format gitlab prints export API_TOKEN='tok_$(...)', and --format github switches to printf instead of echo:

Shell
printf '%s\n' 'API_KEY=secret123' >> "$GITHUB_ENV"

printf '%s\n' prints its argument as is. echo isn't that predictable: depending on the shell, it interprets backslashes or treats a -n as an option.

Why '\''#

The most cryptic part of the fix is turning each ' into '\''. Inside single quotes nothing can be escaped, not even another single quote, so the trick is to step out of them for a moment. A value like it's becomes 'it'\''s', which the shell reads as three pieces glued together:

  1. 'it': text in single quotes.
  2. \': an escaped single quote, outside the quotes.
  3. 's': more text in single quotes.

The shell joins words written together without spaces, so the result is exactly it's. And at no point is there text outside the quotes, except that escaped quote, which can't do anything. The fix's shell_quote_escapes_single_quotes test checks this case with a'b.

On top of that, with github and gitlab variable names must be valid shell identifiers; otherwise the export fails before printing anything. And what I like most about the fix is its tests: they don't compare strings, they actually run the generated script with sh and check that nothing was executed:

Rust
#[cfg(unix)]
#[test]
fn github_does_not_execute_command_substitution() {
    let value = "p@$(echo PWNED)`echo PWNED2`\"'\\";
    let (env, stdout) = run_github_script(&format_github("PASS", value, false));
    assert_eq!(env, format!("PASS={value}\n"));
    assert!(!stdout.contains("PWNED"));
}
The rule

If a piece of data ends up inside something that gets interpreted, whether SQL, HTML or a shell, it needs that language's exact escaping. And if you can, don't generate code at all: pass the data through a channel that is never interpreted.

The second vector: newlines in $GITHUB_ENV#

$GITHUB_ENV is a file the GitHub runner reads line by line: each NAME=value sets a variable for the job's next steps. If a value contained a newline, whatever comes after it would be read as another variable. And some variables are dangerous on their own: LD_PRELOAD makes every process load a library, and BASH_ENV makes every Bash script run a file first. (NODE_OPTIONS would be another, but the GitHub runner already rejects it in $GITHUB_ENV.)

Today Vaultic's .env parser reads line by line, so a value can't carry a real newline. But the output format mustn't depend on that: the roadmap includes YAML and JSON parsers, which do allow multi-line values. For those values GitHub defines its own syntax, NAME<<DELIMITER, and its documentation warns that the delimiter must not appear as a line within the value. Version 1.4.3 guarantees it by construction:

src/cli/commands/ci.rs (1.4.3)
fn github_delimiter(value: &str) -> String {
    use sha2::{Digest, Sha256};
    let hash = format!("{:x}", Sha256::digest(value.as_bytes()));
    let mut delim = format!("VAULTIC_EOF_{}", &hash[..16]);
    while value.lines().any(|l| l == delim) {
        delim.push('_');
    }
    delim
}

The delimiter comes from the hash of the value itself and, if it still showed up as a line, it grows until it no longer does. A value can't close the block early.

The smaller bugs behind it#

An injection bug rarely travels alone. Reviewing the rest of the paths secrets go through turned up four more, of lower severity, that 1.4.3 also fixes.

1. Secrets the mask didn't hide#

--mask emits ::add-mask::value, which asks the GitHub runner to hide that value in the logs. But the runner decodes %25, %0D and %0A in its commands' data, as its ActionCommand.cs shows. A secret literally containing %0A was registered with a different value, and the original showed up in clear in the log. The fix escapes % as %25 before sending it.

The same runner has another quirk: it reads each step's output with .NET's ReadLine, which also splits on a lone \r. A secret with a \r inside reached the log split across two lines, and the mask for the full value matched neither. Now each piece is masked separately.

2. Private keys readable by other users#

Private keys were written with the umask's default permissions, usually 0644: readable by any user on the machine. Now they're created with 0600, inside a 0700 directory, like decrypted .env files. And vaultic status warns when your key's permissions are too open, with the same check ssh applies.

3. A hash that confirmed passwords#

The audit log, audit.log, is committed with the repository and holds no values, only metadata. But on decryption, 1.4.2 stored the SHA-256 of the decrypted file. A hash can't be reversed, but it can be checked: if you guess the content, you compute its hash and compare. And a .env file's content is very guessable, because the variable names are in .env.template and many values are predictable. This is the real entry 1.4.2 left in my test environment, with a test git identity, without the date and formatted for reading:

JSON
{
  "author": "Ana",
  "email": "ana@example.com",
  "action": "decrypt",
  "files": ["prod.env.enc"],
  "detail": "2 variables decrypted to .env",
  "state_hash": "60d0ff82284ff8a044f0486f168f11fcf4c4efa7207a09bf5326e46318529951"
}

And this script, which only uses that hash, confirms the password without decrypting anything:

guess.sh
#!/bin/sh
# sh guess.sh   (in the project root, after decrypting with 1.4.2)
# The hash 1.4.2 left in audit.log on decrypt (the log is committed)
target=$(tail -1 .vaultic/audit.log | sed 's/.*"state_hash":"\([0-9a-f]*\)".*/\1/')
# The keys come from .env.template; only the password is unknown
for guess in 123456 password winter2024 summer2024 qwerty; do
  h=$(printf 'PORT=5432\nDB_PASSWORD=%s\n' "$guess" | sha256sum | cut -d' ' -f1)
  [ "$h" = "$target" ] && echo "confirmed: DB_PASSWORD=$guess" && exit
done
echo "no match"

And its real output:

salidaoutput
confirmed: DB_PASSWORD=summer2024

With a weak password a dictionary is enough; with a strong random one there's nothing to be done. But a secrets tool shouldn't hand out an oracle for checking guesses. Version 1.4.3 stores the hash of the encrypted file, which still works for detecting tampering and says nothing about the content.

4. Half-written files#

If encrypt --all was interrupted halfway through writing a .enc, the file could be left truncated. Now everything is written atomically: first to a temporary file in the same directory, which is synced to disk and then renamed over the original. A crash leaves the old file or the new one, never half of one.

Disclosing it#

Fixing it isn't enough: whoever runs a vulnerable version has to find out. These were the steps:

  1. Version 1.4.3, with binaries verifiable through SHA256 and a minisign signature, also published on crates.io.
  2. A GitHub security advisory, GHSA-5cfx-fmm5-7p2f, with the impact, the affected versions (1.4.0 to 1.4.2; 1.3.0 didn't have ci export yet), the fix and workarounds for anyone who can't upgrade yet. Once GitHub reviews it, it goes into its public advisory database, which tools like Dependabot draw from.
  3. A CVE, requested from GitHub, which is a CVE numbering authority. It's under review; I'll add the number here once it's assigned.
  4. RustSec, the Rust ecosystem's advisory database that cargo audit queries. I prepared the advisory in their format and validated it with their own linter before submitting it, but a maintainer closed the PR: Vaultic doesn't meet their minimum popularity, with 29 recent downloads. The advisory was correct; the rule is theirs, and it makes sense for a database maintained by volunteers. The practical consequence is that cargo audit won't warn about this bug, so the channels that count are the GitHub advisory and the release notes.

The same release updates rustls, used by the update check, for five RustSec advisories. And the repository now runs cargo audit every week and whenever the dependencies change.

What I take away#

  • Never put data inside code someone else will run. If a value ends up inside something that gets interpreted, it needs that language's exact escaping. For a POSIX shell, single quotes.
  • Documenting eval is a strong promise. If your tool generates code for the user to run, that output is attack surface and deserves tests that actually run it.
  • Encrypted content doesn't review itself. Treat changes to .vaultic/ as code changes: require review, for example with CODEOWNERS, and have someone with a key decrypt and compare them before approving. Whoever controls a job's secrets controls its environment.
  • Removing a key doesn't erase the past. When someone leaves the team, vaultic keys remove and encrypt --all protect the new versions, but the old ones are still in the Git history, encrypted for their key too. You have to rotate the values: passwords, tokens and API keys.
  • A secret's hash is an oracle. If you need to detect changes, hash the ciphertext, or use an HMAC with a key that isn't in the repository.
  • Disclose even if your project is small. The public advisory is what lets each user know whether they're affected and what to do.
Personal note

Vaultic is a small project: as of today it has 145 downloads on crates.io. Even so, the bug was real, with a clear path to running code in a CI. And it wasn't in the cryptography, which is the part that gets the most care, but in a line that formatted text. In any tool that generates scripts, that's the first place to look: the exact point where data turns into code.

If you use Vaultic#

Upgrade:

Shell
vaultic update            # or: cargo install vaultic --force

If you can't yet, don't use eval with --format github or --format gitlab. Use vaultic ci export --format generic > .env or vaultic decrypt --env <env> --stdout with a .env loader that doesn't run code, and review recent changes to .vaultic/*.env.enc made by people who shouldn't control your CI.

ReferenciasReferences

  1. GHSA-5cfx-fmm5-7p2f: Command injection in vaultic ci export through secret valuesGitHub · github.com
  2. Vaultic v1.4.3 (release notes)GitHub · github.com
  3. fix: security hardening for v1.4.3 (pull request #7)GitHub · github.com
  4. Workflow commands for GitHub ActionsGitHub Docs · docs.github.com
  5. actions/runner: ActionCommand.csGitHub · github.com
  6. The age file formatC2SP · github.com
  7. Shell Command Language (POSIX.1-2024), 2.2 QuotingThe Open Group · pubs.opengroup.org
  8. CWE-78: Improper Neutralization of Special Elements used in an OS CommandMITRE · cwe.mitre.org
  9. CWE-93: Improper Neutralization of CRLF SequencesMITRE · cwe.mitre.org
  10. CVSS v3.1 Specification DocumentFIRST · first.org
  11. Keeping your GitHub Actions and workflows secure Part 2: Untrusted inputGitHub Security Lab · securitylab.github.com

CompartirShare

¿Quieres una revisión de seguridad de tu herramienta o tu pipeline?Want a security review of your tool or pipeline?

Reviso código, pipelines de CI/CD y gestión de secretos para encontrar fallos como los de esta entrada antes de que lleguen a producción, y te ayudo a corregirlos.I review code, CI/CD pipelines and secrets handling to find bugs like the ones in this post before they reach production, and help you fix them.

HablemosLet's talk→

ComentariosComments

Para comentar necesitas una cuenta de GitHub. Los comentarios se guardan en GitHub Discussions (SoftDryzz/blog-comments) y se moderan: no se publican enlaces promocionales.Commenting requires a GitHub account. Comments live in GitHub Discussions (SoftDryzz/blog-comments) and are moderated: no promotional links.

Sigue leyendoKeep reading

RustRust

Usar C++ desde Rust: cómo un envoltorio seguro convierte un use-after-free en un error de compilaciónUsing C++ from Rust: how a safe wrapper turns a use-after-free into a compile error

Una librería C++ usada desde Rust: qué es unsafe de verdad, cómo se diseña la frontera en C, por qué un bug de C++ deja de compilar con el envoltorio correcto y qué pasa con las excepciones y los pánicos. Con código real.A C++ library used from Rust: what unsafe really is, how to design the C boundary, why a C++ bug stops compiling with the right wrapper, and what happens to exceptions and panics. With real code.

· 17 min

Bajo nivelLow-level

Anatomía de un driver de Windows: del DriverEntry al IRPAnatomy of a Windows driver: from DriverEntry to the IRP

Qué pasa dentro de un driver de Windows: DriverEntry, IRQL, el viaje de un IRP, la tabla de dispatch, los IOCTL y por qué un spinlock mal usado tumba el sistema. Con código real.What happens inside a Windows driver: DriverEntry, IRQL, an IRP's journey, the dispatch table, IOCTLs and why a misused spinlock brings the system down. With real code.

· 12 min