보안 고려사항
스크립트가 사용자 입력을 받거나 파일을 처리하거나 외부 데이터를 다룬다면, 보안을 고려해야 합니다. 웹 애플리케이션의 SQL 인젝션과 마찬가지로, 쉘 스크립트에도 명령어 인젝션 같은 취약점이 있습니다.
입력 검증 — 사용자 입력을 명령어에 직접 넣지 않기
사용자가 입력한 값을 그대로 명령어에 사용하면 위험합니다. 악의적인 입력이 예상치 못한 명령어를 실행시킬 수 있습니다.
# 파일: input_validation.sh#!/usr/bin/env bashset -euo pipefail# 위험한 패턴: 입력값을 검증 없이 사용dangerous_list() { local user_input="$1" ls "$user_input" # user_input이 "-la /etc"이면?}# 안전한 패턴: 입력값 검증 후 사용safe_list() { local user_input="$1" # 1. 절대 경로인지 확인 if [[ "$user_input" != /* ]]; then echo "오류: 절대 경로만 허용합니다." >&2 return 1 fi # 2. 허용 문자만 포함하는지 확인 (알파벳, 숫자, /, -, _, . 만 허용) if [[ ! "$user_input" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then echo "오류: 허용되지 않은 문자가 포함되어 있습니다." >&2 return 1 fi # 3. 디렉토리 탈출 시도 방지 if [[ "$user_input" == *".."* ]]; then echo "오류: 상위 디렉토리 참조는 허용되지 않습니다." >&2 return 1 fi ls -- "$user_input"}echo "파일을 나열할 디렉토리를 입력하세요."read -r user_dirsafe_list "$user_dir"
--는 옵션의 끝을 알립니다. ls -- "$dir"처럼 사용하면 변수 값이 -la처럼 -로 시작해도 옵션으로 해석되지 않습니다.
명령어 인젝션 방어
명령어 인젝션은 사용자 입력이 명령어의 일부로 해석될 때 발생합니다.
# 파일: injection_demo.sh#!/usr/bin/env bash# 위험: eval을 사용하거나 변수를 따옴표 없이 사용# 입력값: "hello; rm -rf /tmp/important"dangerous_echo() { local input="$1" eval "echo $input" # 세미콜론 뒤의 rm이 실행됨}# 안전: 변수를 항상 따옴표로 감싸기safe_echo() { local input="$1" echo "$input" # 그냥 문자열로 전달}# 위험: 명령어 구성에 변수를 직접 사용# 입력값: "file.txt; cat /etc/passwd"dangerous_view() { local filename="$1" cat /logs/$filename # 경로 조작 가능}# 안전: 파일명만 추출해서 사용safe_view() { local filename="$1" # basename으로 경로 조작 방지 local safe_name safe_name=$(basename "$filename") cat "/logs/${safe_name}"}
eval 사용 금지
eval은 문자열을 Bash 명령어로 실행합니다. 사용자 입력이 포함된 문자열을 eval에 넘기면 임의의 명령어가 실행됩니다.
# 위험: eval에 외부 입력 포함user_cmd="ls /tmp"eval "$user_cmd" # 여기까지는 괜찮아 보이지만# 공격자가 이렇게 입력하면user_cmd="ls /tmp; cat /etc/shadow; rm -rf /"eval "$user_cmd" # 재앙# 안전한 대안들# 1. 배열로 명령어 구성cmd=(ls /tmp)"${cmd[@]}" # 각 원소가 별도 인수로 전달# 2. 직접 함수 호출case "$action" in list) ls "$target_dir" ;; count) wc -l "$target_file" ;; *) echo "허용되지 않은 작업: $action" >&2; exit 1 ;;esac
eval이 꼭 필요한 경우는 실제로 거의 없습니다. 연관 배열, 함수, case 문으로 대부분 대체할 수 있습니다.
임시 파일 보안 — mktemp와 umask
임시 파일을 직접 만들면 경쟁 조건(race condition)이 생깁니다.
# 위험: 예측 가능한 파일명 (공격자가 먼저 만들 수 있음)TEMP_FILE="/tmp/myapp_$$.tmp" # $$는 예측 불가하지 않음echo "민감한 데이터" > "$TEMP_FILE"# 안전: mktemp 사용TEMP_FILE=$(mktemp) # /tmp/tmp.XXXXXXXXXX (충분히 무작위)echo "민감한 데이터" > "$TEMP_FILE"
mktemp는 파일을 생성하는 것과 이름을 결정하는 것을 원자적으로 처리합니다. 공격자가 개입할 틈이 없습니다.
umask를 설정하면 생성되는 파일의 기본 권한을 제한합니다.
# 파일: secure_tempfile.sh#!/usr/bin/env bashset -euo pipefail# 파일 생성 시 다른 사용자가 읽지 못하도록 설정umask 077 # 생성 파일: 600 (rw-------), 디렉토리: 700TEMP_FILE=$(mktemp)TEMP_DIR=$(mktemp -d)cleanup() { rm -f "$TEMP_FILE" rm -rf "$TEMP_DIR"}trap cleanup EXIT# 이제 임시 파일은 소유자만 읽고 쓸 수 있음echo "민감한 데이터" > "$TEMP_FILE"ls -la "$TEMP_FILE"# -rw------- 1 user user 15 Apr 24 09:30 /tmp/tmp.AbCdEf
권한 최소화 원칙
스크립트는 필요한 최소한의 권한으로 실행되어야 합니다.
# 파일: least_privilege.sh#!/usr/bin/env bash# root 권한이 필요한지 확인if [[ $EUID -eq 0 ]]; then echo "경고: root로 실행 중입니다. 가능하면 일반 사용자로 실행하세요." >&2fi# 특정 작업에만 sudo 사용# 전체 스크립트를 root로 실행하는 대신 필요한 명령어만 sudoregular_task() { echo "일반 작업: 권한 불필요" ls /tmp}privileged_task() { echo "권한 필요한 작업" sudo systemctl restart nginx # 이 명령어만 sudo}regular_taskprivileged_taskregular_task # 다시 일반 권한으로
민감 정보 처리 — 로그에 비밀번호 출력 금지
# 파일: sensitive_info.sh#!/usr/bin/env bashset -euo pipefail# 위험: 비밀번호를 환경변수나 명령줄 인수로 전달# mysql -u root -p"$DB_PASSWORD" mydb # ps로 비밀번호 노출# 안전: 설정 파일로 자격증명 전달connect_db() { local config_file="$1" mysql --defaults-file="$config_file" mydb}# 위험: 비밀번호를 로그에 출력# echo "연결: mysql://$DB_USER:$DB_PASSWORD@$DB_HOST"# 안전: 민감 정보 마스킹log_connection() { local user="$1" local host="$2" echo "연결: mysql://${user}:***@${host}" # 비밀번호 마스킹}# 히스토리에서 민감한 명령어 제외# 명령어 앞에 공백을 붙이면 히스토리에 저장되지 않음 (HISTCONTROL=ignorespace)# 또는 HISTIGNORE 패턴 설정
SUID 스크립트 위험성
SUID(Set User ID) 비트가 설정된 스크립트는 실행자가 아닌 소유자 권한으로 실행됩니다. 쉘 스크립트에 SUID를 설정하는 것은 매우 위험합니다.
# 위험: SUID 쉘 스크립트 (하지 마세요)# chmod u+s /path/to/script.sh # 절대 하지 마세요# 안전한 대안: sudo 설정으로 특정 명령만 허용# /etc/sudoers에 추가:# user ALL=(ALL) NOPASSWD: /usr/local/bin/specific_command
쉘 스크립트는 해석기(bash)가 실행되는 것이지 스크립트 파일이 직접 실행되는 것이 아닙니다. SUID 비트가 있어도 많은 시스템에서 무시됩니다. 꼭 필요하다면 C로 작성한 래퍼 프로그램을 사용하거나 sudo 정책으로 해결합니다.
실습 — 보안 취약 스크립트를 안전한 버전으로 리팩토링
사용자 입력으로 파일을 검색하는 취약한 스크립트를 안전하게 수정합니다.
# 파일: vulnerable_search.sh (수정 전)#!/bin/bashecho "검색할 키워드를 입력하세요:"read keywordecho "검색할 디렉토리를 입력하세요:"read dir# 위험: 입력 검증 없음, eval 사용, 따옴표 없음result=$(eval "grep -r $keyword $dir")echo "결과: $result"
#!/usr/bin/env bash# 수정: vulnerable_search.shset -euo pipefailreadonly MAX_KEYWORD_LENGTH=100readonly ALLOWED_SEARCH_DIRS=("/tmp" "/home" "/var/log")validate_keyword() { local keyword="$1" if [[ ${#keyword} -gt $MAX_KEYWORD_LENGTH ]]; then echo "오류: 키워드가 너무 깁니다 (최대 ${MAX_KEYWORD_LENGTH}자)." >&2 return 1 fi # 특수 문자 허용 안 함 (영문, 숫자, 공백, 기본 기호만) if [[ ! "$keyword" =~ ^[a-zA-Z0-9가-힣\ ._-]+$ ]]; then echo "오류: 키워드에 허용되지 않은 문자가 포함되어 있습니다." >&2 return 1 fi}validate_directory() { local dir="$1" local allowed=false # 허용된 디렉토리 목록에 있는지 확인 for allowed_dir in "${ALLOWED_SEARCH_DIRS[@]}"; do if [[ "$dir" == "$allowed_dir" || "$dir" == "$allowed_dir/"* ]]; then allowed=true break fi done if ! $allowed; then echo "오류: 허용되지 않은 디렉토리입니다: $dir" >&2 echo "허용 디렉토리: ${ALLOWED_SEARCH_DIRS[*]}" >&2 return 1 fi if [[ ! -d "$dir" ]]; then echo "오류: 디렉토리가 존재하지 않습니다: $dir" >&2 return 1 fi}echo "검색할 키워드를 입력하세요."read -r keywordecho "검색할 디렉토리를 입력하세요 (허용: ${ALLOWED_SEARCH_DIRS[*]})."read -r search_dirvalidate_keyword "$keyword" || exit 1validate_directory "$search_dir" || exit 1echo "검색 중: '$keyword' in '$search_dir'"# eval 없이 안전하게 실행grep -r -- "$keyword" "$search_dir" 2>/dev/null || echo "검색 결과가 없습니다."
수정 전과 후의 차이를 정리하면 다음과 같습니다. eval을 제거하고 직접 명령어를 실행합니다. 키워드 길이와 허용 문자를 검증합니다. 검색 디렉토리를 화이트리스트로 제한합니다. read -r로 백슬래시 해석을 방지합니다. --로 옵션 인젝션을 방지합니다. 보안은 한 번에 완성되는 것이 아닙니다. 위협 모델을 생각하고, 입력을 의심하고, 권한을 최소화하는 습관이 쌓여서 안전한 스크립트가 됩니다.