¿Por qué se debe evitar eval en Bash, y qué debo usar en su lugar?


Una y otra vez, veo respuestas Bash en el Desbordamiento de la pila usando eval y las respuestas son golpeadas, juego de palabras, para el uso de una construcción "malvada". ¿Por qué es eval tan malvado?

Si eval no se puede usar de forma segura, ¿qué debo usar en su lugar?

Author: codeforester, 2013-07-08

3 answers

Hay más en este problema de lo que parece. Comenzaremos con lo obvio: eval tiene el potencial de ejecutar datos "sucios". Los datos sucios son todos los datos que no se han reescrito como safe-for-use-in-situation-XYZ; en nuestro caso, es cualquier cadena que no se ha formateado para ser segura para su evaluación.

La desinfección de datos parece fácil a primera vista. Suponiendo que estamos lanzando una lista de opciones, bash ya proporciona una gran manera de desinfectar elementos individuales, y otra forma de desinfectar toda la matriz como una sola cadena:

function println
{
    # Send each element as a separate argument, starting with the second element.
    # Arguments to printf:
    #   1 -> "$1\n"
    #   2 -> "$2"
    #   3 -> "$3"
    #   4 -> "$4"
    #   etc.

    printf "$1\n" "${@:2}"
}

function error
{
    # Send the first element as one argument, and the rest of the elements as a combined argument.
    # Arguments to println:
    #   1 -> '\e[31mError (%d): %s\e[m'
    #   2 -> "$1"
    #   3 -> "${*:2}"

    println '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit "$1"
}

# This...
error 1234 Something went wrong.
# And this...
error 1234 'Something went wrong.'
# Result in the same output (as long as $IFS has not been modified).

Ahora digamos que queremos agregar una opción para redirigir la salida como un argumento a println. Podríamos, por supuesto, redirigir la salida de println en cada llamada, pero por ejemplo, no vamos a hacer eso. Necesitaremos usar eval, ya que las variables no se pueden usar para redirigir la salida.

function println
{
    eval printf "$2\n" "${@:3}" $1
}

function error
{
    println '>&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit $1
}

error 1234 Something went wrong.

Se ve bien, ¿verdad? El problema es que eval analiza dos veces la línea de comandos (en cualquier shell). En el primer paso de análisis se elimina una capa de entrecomillado. Con las comillas eliminadas, se ejecuta parte del contenido variable.

Podemos arreglar esto dejando que la expansión de la variable tenga lugar dentro de eval. Todo lo que tenemos que hacer es comillas simples, dejando las comillas dobles donde están. Una excepción: tenemos que expandir la redirección antes de eval, por lo que tiene que permanecer fuera de las comillas:

function println
{
    eval 'printf "$2\n" "${@:3}"' $1
}

function error
{
    println '&2' '\e[31mError (%d): %s\e[m' "$1" "${*:2}"
    exit $1
}

error 1234 Something went wrong.

Esto debería funcionar. También es seguro siempre y cuando $1 en println nunca esté sucio.

Ahora espere un momento: ¡Utilizo la misma sintaxis sin comillas que usamos originalmente con sudo todo el tiempo! ¿Por qué funciona allí y no aquí? ¿Por qué tuvimos que citar todo? sudo es un poco más moderno: sabe encerrar entre comillas cada argumento que recibe, aunque eso es una simplificación excesiva. eval simplemente concatena todo.

Desafortunadamente, no hay un reemplazo drop-in para eval que trate argumentos como sudo hace, como eval es un shell integrado; esto es importante, ya que toma el entorno y el ámbito del código circundante cuando se ejecuta, en lugar de crear una nueva pila y ámbito como lo hace una función.

Alternativas de evaluación

Los casos de uso específicos a menudo tienen alternativas viables a eval. Aquí hay una lista útil. command representa lo que normalmente enviarías a eval; sustituye lo que quieras.

No-op

Dos puntos simples en un no-op en bash: :

Crear a sub-shell

( command )   # Standard notation

Ejecutar la salida de un comando

Nunca confíe en un comando externo. Siempre debe tener el control del valor devuelto. Ponga estos en sus propias líneas:

$(command)   # Preferred
`command`    # Old: should be avoided, and often considered deprecated

# Nesting:
$(command1 "$(command2)")
`command "\`command\`"`  # Careful: \ only escapes $ and \ with old style, and
                         # special case \` results in nesting.

Redirección basada en la variable

En el código de llamada, mapea &3 (o cualquier cosa mayor que &2) a tu objetivo:

exec 3<&0         # Redirect from stdin
exec 3>&1         # Redirect to stdout
exec 3>&2         # Redirect to stderr
exec 3> /dev/null # Don't save output anywhere
exec 3> file.txt  # Redirect to file
exec 3> "$var"    # Redirect to file stored in $var--only works for files!
exec 3<&0 4>&1    # Input and output!

Si fuera una llamada de una sola vez, no tendría que redirigir todo el shell:

func arg1 arg2 3>&2

Dentro de la función que se llama, redirige a &3:

command <&3       # Redirect stdin
command >&3       # Redirect stdout
command 2>&3      # Redirect stderr
command &>&3      # Redirect stdout and stderr
command 2>&1 >&3  # idem, but for older bash versions
command >&3 2>&1  # Redirect stdout to &3, and stderr to stdout: order matters
command <&3 >&4   # Input and output!

Variable indirecta

Escenario:

VAR='1 2 3'
REF=VAR

Malo:

eval "echo \"\$$REF\""

¿Por qué? Si REF contiene una comilla doble, esto se romperá y abrirá el código a exploits. Es posible desinfectar REF, pero es una pérdida de tiempo cuando tienes esto:

echo "${!REF}"

Así es, bash tiene direccion variable incorporada a partir de la versión 2. Se pone un poco más complicado que eval si quieres hacer algo más complejo:

# Add to scenario:
VAR_2='4 5 6'

# We could use:
local ref="${REF}_2"
echo "${!ref}"

# Versus the bash < 2 method, which might be simpler to those accustomed to eval:
eval "echo \"\$${REF}_2\""

Independientemente, el nuevo método es más intuitivo, aunque podría no parecer de esa manera a los programados experimentados que están acostumbrados a eval.

Matrices asociativas

Los arrays asociativos se implementan intrínsecamente en bash 4. Una advertencia: deben ser creados usando declare.

declare -A VAR   # Local
declare -gA VAR  # Global

# Use spaces between parentheses and contents; I've heard reports of subtle bugs
# on some versions when they are omitted having to do with spaces in keys.
declare -A VAR=( ['']='a' [0]='1' ['duck']='quack' )

VAR+=( ['alpha']='beta' [2]=3 )  # Combine arrays

VAR['cow']='moo'  # Set a single element
unset VAR['cow']  # Unset a single element

unset VAR     # Unset an entire array
unset VAR[@]  # Unset an entire array
unset VAR[*]  # Unset each element with a key corresponding to a file in the
              # current directory; if * doesn't expand, unset the entire array

local KEYS=( "${!VAR[@]}" )  # Get all of the keys in VAR

En versiones anteriores de bash, puedes usar la variable indirecta:

VAR=( )  # This will store our keys.

# Store a value with a simple key.
# You will need to declare it in a global scope to make it global prior to bash 4.
# In bash 4, use the -g option.
declare "VAR_$key"="$value"
VAR+="$key"
# Or, if your version is lacking +=
VAR=( "$VAR[@]" "$key" )

# Recover a simple value.
local var_key="VAR_$key"       # The name of the variable that holds the value
local var_value="${!var_key}"  # The actual value--requires bash 2
# For < bash 2, eval is required for this method.  Safe as long as $key is not dirty.
local var_value="`eval echo -n \"\$$var_value\""

# If you don't need to enumerate the indices quickly, and you're on bash 2+, this
# can be cut down to one line per operation:
declare "VAR_$key"="$value"                         # Store
echo "`var_key="VAR_$key" echo -n "${!var_key}"`"   # Retrieve

# If you're using more complex values, you'll need to hash your keys:
function mkkey
{
    local key="`mkpasswd -5R0 "$1" 00000000`"
    echo -n "${key##*$}"
}

local var_key="VAR_`mkkey "$key"`"
# ...
 120
Author: Zenexer,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-12-19 22:37:00

Cómo hacer eval seguro

eval puede ser utilizado con seguridad - pero todos sus argumentos deben ser citados primero. He aquí cómo:

Esta función que lo hará por usted:

function token_quote {
  local quoted=()
  for token; do
    quoted+=( "$(printf '%q' "$token")" )
  done
  printf '%s\n' "${quoted[*]}"
}

Ejemplo de uso:

Dada alguna entrada de usuario no confiable:

% input="Trying to hack you; date"

Construir un comando para evaluar:

% cmd=(echo "User gave:" "$input")

Eval it, with seemingly correct quoting:

% eval "$(echo "${cmd[@]}")"
User gave: Trying to hack you
Thu Sep 27 20:41:31 +07 2018

Tenga en cuenta que fue hackeado. date fue ejecutado en lugar de ser impreso literalmente.

En lugar de token_quote():

% eval "$(token_quote "${cmd[@]}")"
User gave: Trying to hack you; date
%

eval no es malo-es simplemente incomprendido:)

 2
Author: Tom Hale,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-09-29 05:24:43

¿Qué pasa con

ls -la /path/to/foo | grep bar | bash

O

(ls -la /path/to/foo | grep bar) | bash

?

 -3
Author: skeetastax,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-06-05 06:02:59