Dlaczego należy unikać eval w Bash i czego zamiast tego używać?

107

Raz po raz widzę odpowiedzi Basha na Stack Overflow, evala odpowiedzi są atakowane, gra słów przeznaczona do użycia takiego „złego” konstruktu. Dlaczego jest evaltaki zły?

Jeśli evalnie można go bezpiecznie używać, czego powinienem użyć zamiast tego?

Zenexer
źródło

Odpowiedzi:

148

W tym problemie jest coś więcej niż na pierwszy rzut oka. Zaczniemy od tego, co oczywiste: evalma potencjał do wykonywania „brudnych” danych. Brudne dane to wszelkie dane, które nie zostały przepisane jako bezpieczne do użycia w sytuacji-XYZ; w naszym przypadku jest to dowolny ciąg, który nie został sformatowany tak, aby był bezpieczny do oceny.

Odkażanie danych na pierwszy rzut oka wydaje się łatwe. Zakładając, że rzucamy listę opcji, bash już zapewnia świetny sposób na oczyszczenie poszczególnych elementów i inny sposób na wyczyszczenie całej tablicy jako pojedynczego ciągu:

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).

Teraz powiedzmy, że chcemy dodać opcję przekierowania wyjścia jako argument do println. Moglibyśmy oczywiście po prostu przekierować wyjście println przy każdym wywołaniu, ale dla przykładu, nie zamierzamy tego robić. Będziemy musieli użyć eval, ponieważ zmiennych nie można używać do przekierowywania danych wyjściowych.

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.

Wygląda dobrze, prawda? Problem w tym, że eval analizuje dwukrotnie wiersz poleceń (w dowolnej powłoce). Podczas pierwszego przebiegu analizy usuwana jest jedna warstwa cytatów. Po usunięciu cudzysłowów wykonywana jest zawartość zmiennej.

Możemy to naprawić, pozwalając na rozwijanie zmiennych w ramach eval. Wszystko, co musimy zrobić, to zacytować wszystko w jednym cudzysłowie, pozostawiając podwójne cudzysłowy tam, gdzie są. Jeden wyjątek: musimy rozszerzyć przekierowanie przed eval, aby pozostać poza cudzysłowami:

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.

To powinno działać. Jest to także bezpieczne tak długo, jak $1na printlnto nigdy nie brudne.

A teraz chwileczkę: używam tej samej niecytowanej składni, której pierwotnie używaliśmy przez sudocały czas! Dlaczego to działa tam, a nie tutaj? Dlaczego musieliśmy wszystko cytować pojedynczo? sudojest nieco nowocześniejszy: wie, że każdy otrzymany argument należy ująć w cudzysłów, chociaż jest to nadmierne uproszczenie. evalpo prostu łączy wszystko.

Niestety, nie ma zamiennika typu drop-in, evalktóry traktuje argumenty tak jak sudorobi, podobnie jak evalwbudowana powłoka; jest to ważne, ponieważ podczas wykonywania przyjmuje środowisko i zakres otaczającego kodu, a nie tworzy nowy stos i zakres, jak robi to funkcja.

eval Alternatives

Konkretne przypadki użycia często mają realną alternatywę dla eval. Oto przydatna lista. commandreprezentuje to, do czego normalnie wysyłasz eval; zastępuj cokolwiek chcesz.

No-op

Prosty dwukropek to brak działania w bash:

:

Utwórz podpowłokę

( command )   # Standard notation

Wykonuje wyjście polecenia

Nigdy nie polegaj na zewnętrznym poleceniu. Zawsze powinieneś mieć kontrolę nad zwracaną wartością. Umieść je w osobnych wierszach:

$(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.

Przekierowanie oparte na zmiennej

W wywołaniu kodu zmapuj &3(lub cokolwiek wyższego niż &2) na swój cel:

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!

Gdyby to było jednorazowe połączenie, nie musiałbyś przekierowywać całej powłoki:

func arg1 arg2 3>&2

W ramach wywoływanej funkcji przekieruj do &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!

Zmienna pośrednia

Scenariusz:

VAR='1 2 3'
REF=VAR

Zły:

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

Czemu? Jeśli REF zawiera podwójny cudzysłów, spowoduje to uszkodzenie i otwarcie kodu na exploity. Odkażanie REF jest możliwe, ale szkoda czasu, gdy masz to:

echo "${!REF}"

Zgadza się, bash ma wbudowaną zmienną pośrednią od wersji 2. Jest to nieco trudniejsze niż w evalprzypadku, gdy chcesz zrobić coś bardziej złożonego:

# 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\""

Mimo wszystko nowa metoda jest bardziej intuicyjna, chociaż może się tak nie wydawać doświadczonym programistom, którzy są do tego przyzwyczajeni eval.

Tablice asocjacyjne

Tablice asocjacyjne są implementowane wewnętrznie w bash 4. Jedno zastrzeżenie: muszą być tworzone przy użyciu 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

W starszych wersjach basha możesz używać zmiennej pośredniej:

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"`"
# ...
Zenexer
źródło
4
Brakuje mi wzmianki o eval "export $var='$val'"... (?)
Zrin
1
@Zrin Są szanse, że nie robi tego, czego oczekujesz. export "$var"="$val"jest prawdopodobnie tym, czego chcesz. Jedyny przypadek, w którym możesz użyć swojego formularza, to jeśli var='$var2'i chcesz go podwójnie wyodrębnić - ale nie powinieneś próbować robić czegoś takiego w bashu. Jeśli naprawdę musisz, możesz użyć export "${!var}"="$val".
Zenexer
1
@anishsane: Dla twojego przypuszczenia , a x="echo hello world";następnie do wykonania tego, co jest zawarte w x, możemy użyć eval $xJednak $($x)jest źle, prawda? Tak: $($x)jest źle, ponieważ działa, echo hello worlda następnie próbuje uruchomić przechwycone dane wyjściowe (przynajmniej w kontekstach, w których myślę, że ich używasz), co nie powiedzie się, chyba że masz program o nazwie hellokopanie.
Jonathan Leffler
1
@tmow Ach, więc naprawdę chcesz eval funkcjonalność. Jeśli tego chcesz, możesz użyć eval; pamiętaj tylko, że ma wiele zastrzeżeń dotyczących bezpieczeństwa. To również znak, że w Twojej aplikacji występuje błąd projektowy.
Zenexer,
1
ref="${REF}_2" echo "${!ref}"przykład jest zły, nie będzie działać zgodnie z przeznaczeniem, ponieważ bash podstawia zmienne przed wykonaniem polecenia. Jeśli refzmienna jest wcześniej naprawdę niezdefiniowana, wynikiem podstawienia będzie ref="VAR_2" echo ""i to właśnie zostanie wykonane.
Yoory N.
17

Jak się evalzabezpieczyć

eval można bezpiecznie używać - ale wszystkie jego argumenty należy najpierw zacytować. Oto jak:

Ta funkcja, która zrobi to za Ciebie:

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

Przykładowe użycie:

Biorąc pod uwagę niezaufane dane wejściowe użytkownika:

% input="Trying to hack you; date"

Skonstruuj polecenie ewaluacji:

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

Oceń to, pozornie poprawnym cytatem:

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

Zauważ, że zostałeś zhakowany. datezostał wykonany, a nie wydrukowany dosłownie.

Zamiast tego token_quote():

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

eval nie jest zły - jest po prostu źle zrozumiany :)

Tom Hale
źródło
W jaki sposób funkcja „token_quote” wykorzystuje swoje argumenty? Nie mogę znaleźć żadnej dokumentacji na temat tej funkcji ...
Akito
Myślę, że sformułowałem to zbyt niejasno. Miałem na myśli argumenty funkcji. Dlaczego nie ma arg="$1"? Skąd pętla for wie, które argumenty zostały przekazane do funkcji?
Akito
Poszedłbym dalej niż po prostu „niezrozumiany”, jest też często niewłaściwie używany i naprawdę nie jest potrzebny. Odpowiedź Zenexera obejmuje wiele takich przypadków, ale każde użycie evalpowinno być czerwoną flagą i dokładnie zbadane, aby potwierdzić, że naprawdę nie ma lepszej opcji już dostarczonej przez język.
dimo414