เหตุใดจึงควรหลีกเลี่ยงการประเมินใน Bash และฉันควรใช้อะไรแทน


107

ครั้งแล้วครั้งเล่าที่ฉันเห็นคำตอบของ Bash ใน Stack Overflow โดยใช้evalและคำตอบได้รับการเจาะรูโดยตั้งใจสำหรับการใช้โครงสร้าง "ชั่วร้าย" ดังกล่าว ทำไมevalชั่วจัง

ถ้าevalใช้ไม่ได้อย่างปลอดภัยควรใช้อะไรแทน?

คำตอบ:


148

ปัญหานี้มีมากกว่าที่เห็น เราจะเริ่มต้นด้วยสิ่งที่ชัดเจน: evalมีศักยภาพในการเรียกใช้ข้อมูล "สกปรก" ข้อมูลสกปรกคือข้อมูลใด ๆ ที่ไม่ได้ถูกเขียนขึ้นใหม่ว่าปลอดภัยสำหรับการใช้งานในสถานการณ์ -XYZ ในกรณีของเราเป็นสตริงใด ๆ ที่ไม่ได้รับการจัดรูปแบบเพื่อให้ปลอดภัยต่อการประเมินผล

ข้อมูลการฆ่าเชื้อดูเหมือนง่ายในแวบแรก สมมติว่าเรากำลังใช้รายการตัวเลือก bash เป็นวิธีที่ยอดเยี่ยมในการทำความสะอาดองค์ประกอบแต่ละรายการและอีกวิธีหนึ่งในการฆ่าเชื้ออาร์เรย์ทั้งหมดเป็นสตริงเดียว:

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

ตอนนี้สมมติว่าเราต้องการเพิ่มตัวเลือกในการเปลี่ยนทิศทางเอาต์พุตเป็นอาร์กิวเมนต์ไปยัง println แน่นอนเราทำได้เพียงแค่เปลี่ยนเส้นทางเอาต์พุตของ println ในแต่ละการโทร แต่เพื่อประโยชน์ของตัวอย่างเราจะไม่ทำเช่นนั้น เราจำเป็นต้องใช้evalเนื่องจากไม่สามารถใช้ตัวแปรเพื่อเปลี่ยนเส้นทางเอาต์พุตได้

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.

ดูดีใช่มั้ย? ปัญหาคือ eval แยกวิเคราะห์สองครั้งของบรรทัดคำสั่ง (ในเชลล์ใด ๆ ) ในขั้นตอนแรกของการแยกคำพูดหนึ่งชั้นจะถูกลบออก เมื่อลบเครื่องหมายคำพูดออกเนื้อหาตัวแปรบางส่วนจะถูกดำเนินการ

เราสามารถแก้ไขได้โดยให้การขยายตัวแปรเกิดขึ้นภายในไฟล์eval. สิ่งที่เราต้องทำคือ single-quote ทุกอย่างปล่อยให้ double-quotes อยู่ตรงไหน ข้อยกเว้นประการหนึ่ง: เราต้องขยายการเปลี่ยนเส้นทางก่อนevalจึงต้องอยู่นอกเครื่องหมายคำพูด:

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.

สิ่งนี้ควรใช้งานได้ นอกจากนี้ยังปลอดภัยตราบเท่าที่$1ภายในprintlnไม่สกปรก

ตอนนี้ค้างไว้สักครู่: ผมใช้ที่เดียวกันunquotedไวยากรณ์ที่เรานำมาใช้กับsudoทุกเวลา! ทำไมมันถึงทำงานที่นั่นไม่ใช่ที่นี่? ทำไมเราต้องอ้างทุกอย่าง? sudoมีความทันสมัยกว่าเล็กน้อย: ควรใส่เครื่องหมายคำพูดแต่ละอาร์กิวเมนต์ที่ได้รับแม้ว่าจะเป็นการทำให้เข้าใจง่ายเกินไป evalเชื่อมต่อทุกอย่างเข้าด้วยกัน

น่าเสียดายที่ไม่มีการแทนที่แบบดร็อปอินสำหรับการevalปฏิบัติต่ออาร์กิวเมนต์เช่นsudoเดียวevalกับเชลล์ในตัว สิ่งนี้มีความสำคัญเนื่องจากต้องใช้กับสภาพแวดล้อมและขอบเขตของโค้ดโดยรอบเมื่อดำเนินการแทนที่จะสร้างสแต็กและขอบเขตใหม่เหมือนที่ฟังก์ชันทำ

ประเมินทางเลือก

กรณีการใช้งานเฉพาะมักมีทางเลือกอื่นที่เป็นไปevalได้ นี่คือรายการที่มีประโยชน์ commandแสดงให้เห็นถึงสิ่งที่คุณตามปกติจะส่งไปeval; แทนที่ในสิ่งที่คุณต้องการ

No-op

ลำไส้ใหญ่ธรรมดาเป็นสิ่งที่ไม่ต้องทำในการทุบตี:

:

สร้างเชลล์ย่อย

( command )   # Standard notation

เรียกใช้เอาต์พุตของคำสั่ง

อย่าพึ่งพาคำสั่งภายนอก คุณควรเป็นผู้ควบคุมมูลค่าส่งคืนเสมอ วางสิ่งเหล่านี้ไว้ในบรรทัดของตัวเอง:

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

การเปลี่ยนเส้นทางตามตัวแปร

ในการเรียกรหัสแผนที่&3(หรือสิ่งที่สูงกว่า&2) ไปยังเป้าหมายของคุณ:

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!

หากเป็นการโทรครั้งเดียวคุณจะไม่ต้องเปลี่ยนเส้นทางเชลล์ทั้งหมด:

func arg1 arg2 3>&2

ภายในฟังก์ชันที่ถูกเรียกเปลี่ยนเส้นทางไปที่&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!

ทิศทางตัวแปร

สถานการณ์:

VAR='1 2 3'
REF=VAR

แย่:

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

ทำไม? หาก REF มีเครื่องหมายคำพูดคู่สิ่งนี้จะทำลายและเปิดรหัสเพื่อหาประโยชน์ เป็นไปได้ที่จะฆ่าเชื้อ REF แต่จะเสียเวลาเมื่อคุณมีสิ่งนี้:

echo "${!REF}"

ถูกต้อง bash มีตัวแปรทิศทางในตัวในเวอร์ชัน 2 มันค่อนข้างยากกว่าevalถ้าคุณต้องการทำสิ่งที่ซับซ้อนกว่านี้:

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

โดยไม่คำนึงถึงวิธีการใหม่ใช้งานง่ายมากขึ้น evalแต่มันอาจจะไม่ดูเหมือนวิธีการที่จะมีประสบการณ์โปรแกรมที่จะใช้ใน

อาร์เรย์เชื่อมโยง

อาร์เรย์ที่เชื่อมโยงถูกนำไปใช้ภายใน bash 4 ข้อแม้ข้อเดียว: ต้องสร้างโดยใช้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

ใน bash เวอร์ชันเก่าคุณสามารถใช้ตัวแปรทิศทาง:

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"`"
# ...

4
ฉันขาดการกล่าวถึงeval "export $var='$val'"... (?)
Zrin

1
@ Zrin โอกาสที่ไม่ได้ทำในสิ่งที่คุณคาดหวัง export "$var"="$val"อาจเป็นสิ่งที่คุณต้องการ ครั้งเดียวที่คุณอาจใช้แบบฟอร์มของคุณคือ if var='$var2'และคุณต้องการอ้างถึงสองครั้ง แต่คุณไม่ควรพยายามทำอะไรแบบนั้นด้วยการทุบตี หากคุณต้องการจริงๆคุณสามารถใช้export "${!var}"="$val".
Zenexer

1
@anishsane: สำหรับสมมติx="echo hello world";xeval $x$($x)ของคุณจากนั้นในการดำเนินการสิ่งที่มีอยู่เราสามารถใช้ได้อย่างไรก็ตามผิดใช่หรือไม่? ใช่: $($x)ผิดเพราะมันทำงานecho hello worldแล้วพยายามเรียกใช้เอาต์พุตที่จับได้ (อย่างน้อยก็ในบริบทที่ฉันคิดว่าคุณใช้อยู่) ซึ่งจะล้มเหลวเว้นแต่คุณจะมีโปรแกรมที่เรียกว่าhellokicking around
Jonathan Leffler

1
@tmow อ่าคุณต้องการฟังก์ชันการประเมินจริงๆ หากนั่นคือสิ่งที่คุณต้องการคุณสามารถใช้ eval; โปรดทราบว่ามีข้อควรระวังด้านความปลอดภัยมากมาย นอกจากนี้ยังเป็นสัญญาณว่ามีข้อบกพร่องด้านการออกแบบในแอปพลิเคชันของคุณ
Zenexer

1
ref="${REF}_2" echo "${!ref}"ตัวอย่างไม่ถูกต้องมันจะไม่ทำงานตามที่ตั้งใจไว้เนื่องจาก bash แทนที่ตัวแปรก่อนที่จะดำเนินการคำสั่ง หากrefไม่ได้กำหนดตัวแปรไว้ก่อนผลของการแทนที่จะเป็นref="VAR_2" echo ""และนั่นคือสิ่งที่จะดำเนินการ
Yoory N.

17

ทำอย่างไรให้evalปลอดภัย

eval สามารถใช้ได้อย่างปลอดภัย - แต่ข้อโต้แย้งทั้งหมดจะต้องได้รับการเสนอราคาก่อน วิธีการมีดังนี้

ฟังก์ชันนี้จะทำเพื่อคุณ:

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

ตัวอย่างการใช้งาน:

ให้ข้อมูลผู้ใช้ที่ไม่น่าเชื่อถือ:

% input="Trying to hack you; date"

สร้างคำสั่งเพื่อ eval:

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

Eval มันมีที่ดูเหมือนจะถูกต้องข้อความ:

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

โปรดทราบว่าคุณถูกแฮ็ก dateถูกประหารชีวิตแทนที่จะพิมพ์ตามตัวอักษร

แทนด้วยtoken_quote():

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

eval ไม่ใช่ความชั่วร้าย - เป็นเพียงความเข้าใจผิด :)


ฟังก์ชัน "token_quote" ใช้อาร์กิวเมนต์อย่างไร ฉันไม่พบเอกสารใด ๆ เกี่ยวกับฟีเจอร์นี้ ...
Akito


ฉันเดาว่าฉันพูดไม่ชัดเจนเกินไป ฉันหมายถึงอาร์กิวเมนต์ของฟังก์ชัน ทำไมไม่มีarg="$1"? for loop รู้ได้อย่างไรว่าอาร์กิวเมนต์ใดถูกส่งไปยังฟังก์ชัน?
Akito

ฉันจะไปไกลกว่าแค่ "เข้าใจผิด" มันมักจะใช้ผิดและไม่จำเป็นจริงๆ คำตอบของ Zenexer ครอบคลุมกรณีดังกล่าวจำนวนมาก แต่การใช้งานใด ๆevalควรเป็นธงสีแดงและได้รับการตรวจสอบอย่างใกล้ชิดเพื่อยืนยันว่าภาษานี้ไม่มีตัวเลือกที่ดีกว่านี้แล้ว
dimo414
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.