셸 명령어 개발 가이드라인

본 문서에는 GitLab 코드베이스에서 프로세스 및 파일을 다루기 위한 지침이 포함되어 있습니다. 이러한 가이드라인은 코드를 더 신뢰할 수 있고 안전하게 만들기 위한 것입니다.

참고 자료

셸 명령어보다는 File 및 FileUtils 사용

가끔은 Ruby API로도 처리할 수 있는데도 셸을 통해 기본 Unix 명령어를 호출하기도 합니다. 만약 Ruby API가 있다면 Ruby API를 사용하세요.

# 잘못된 예
system "mkdir -p tmp/special/directory"
# 더 나은 예 (분리된 토큰 사용)
system *%W(mkdir -p tmp/special/directory)
# 최상의 예 (셸 명령어를 사용하지 마세요)
FileUtils.mkdir_p "tmp/special/directory"

# 잘못된 예
contents = `cat #{filename}`
# 올바른 예
contents = File.read(filename)

# 가끔은 셸 명령어가 최상의 해결책일 수 있습니다. 아래 예시는 사용자 입력이 없고, Ruby에서 올바르게 구현하기 어려운 예시입니다: /some/path 아래에서 /some/path를 포함하지 않는 120분 이전의 파일과 디렉터리를 모두 삭제합니다.
Gitlab::Popen.popen(%W(find /some/path -not -path /some/path -mmin +120 -delete))

이 코딩 스타일은 CVE-2013-4490을 예방할 수 있었습니다.

항상 설정 가능한 Git 바이너리 경로를 사용하여 Git 명령어 사용

# 잘못된 예
system(*%W(git branch -d -- #{branch_name}))

# 올바른 예
system(*%W(#{Gitlab.config.git.bin_path} branch -d -- #{branch_name}))

명령어를 분리된 토큰으로 나누어 셸 우회하기

Ruby에게 단일 문자열로 셸 명령어를 전달할 때, Ruby는 /bin/sh를 통해 전체 문자열을 평가합니다. 본질적으로 우리는 셸에게 한 줄짜리 스크립트를 평가하도록 요청하는 것입니다. 이로 인해 셸 삽입 공격의 위험이 생깁니다. 우리가 스크립팅 기능을 사용하여 작업 디렉터리를 변경하거나 환경 변수를 설정하는 경우가 있습니다. 이 모든 것들은 Ruby에서도 안전하게 직접 수행할 수 있습니다.

# 잘못된 예
system "cd /home/git/gitlab && bundle exec rake db:#{something} RAILS_ENV=production"
# 올바른 예
system({'RAILS_ENV' => 'production'}, *%W(bundle exec rake db:#{something}), chdir: '/home/git/gitlab')

# 잘못된 예
system "touch #{myfile}"
# 더 나은 예
system "touch", myfile
# 최상의 예 (셸 명령어를 아예 실행하지 마세요)
FileUtils.touch myfile

이 코딩 스타일은 CVE-2013-4546을 예방할 수 있었습니다.

:: 코드 블록이 아닌 경우, --을 사용하여 옵션과 인수를 구분하세요.

옵션과 인수를 --로 시스템 명령어의 인수 해석기에 명확하게 구분하십시오. 이는 많은 Unix 명령어에서 지원되지만 전부에서 지원되는 것은 아닙니다.

:: 코드 블록이 아닌 경우, --을 사용하여 옵션과 인수를 구분하기 위한 예시입니다.

# 예시
$ echo hello > -l
$ cat -l

cat: illegal option -- l
usage: cat [-benstuv] [file ...]

위의 예시에서 cat의 인수 해석기는 -l을 옵션이라고 가정합니다. 위의 해결책은 cat에게 -l이 옵션이 아닌 실제로 인수임을 명확하게 하도록 하는 것입니다. 많은 Unix 명령줄 도구들은 옵션과 인수를 --로 구분하는 규칙을 따릅니다.

# 잘못된 예
system(*%W(#{Gitlab.config.git.bin_path} branch -d #{branch_name}))
# 올바른 예
system(*%W(#{Gitlab.config.git.bin_path} branch -d -- #{branch_name}))

이 코딩 스타일은 CVE-2013-4582를 예방할 수 있었습니다.

:: 코드 블록이 아닌 경우,야한 옵션과 인수를 명확하게 구분해주기 위해 --를 사용함으로써 셸 명령어에 대한 모호함을 피합니다.

역따옴표를 사용하지 마세요!

역따옴표를 사용하여 셸 명령어의 출력을 캡처하는 것은 읽기 쉽지만, 명령어를 셸에 전체 문자열로 전달해야 합니다. 위에서 안전하지 않다고 설명한 바에 따라, GitLab 코드베이스의 주요 해결책은 Gitlab::Popen.popen을 사용하는 것입니다.

# 잘못된 예
logs = `cd #{repo_dir} && #{Gitlab.config.git.bin_path} log`
# 올바른 예
logs, exit_status = Gitlab::Popen.popen(%W(#{Gitlab.config.git.bin_path} log), repo_dir)

# 잘못된 예
user = `whoami`
# 올바른 예
user, exit_status = Gitlab::Popen.popen(%W(whoami))

GitLab Shell과 같은 다른 저장소에서는 IO.popen을 사용할 수도 있습니다.

# 안전한 IO.popen 예시
logs = IO.popen(%W(#{Gitlab.config.git.bin_path} log), chdir: repo_dir) { |p| p.read }

:: 주의: Gitlab::Popen.popen과 달리, IO.popen은 표준 오류를 캡처하지 않습니다.

경로 문자열의 시작에 사용자 입력을 피하세요

루비에서 파일을 열고 읽는 여러 가지 방법은 파일의 표준 출력을 읽는 대신 프로세스의 표준 출력을 읽는 데 사용될 수 있습니다. 다음의 두 명령어는 대략적으로 같은 동작을 합니다:

`touch /tmp/pawned-by-backticks`
File.read('|touch /tmp/pawned-by-file-read')

핵심은 |로 시작하는 ‘파일’을 열 수 있는 것입니다. 영향을 받는 메서드에는 Kernel#open, File::read, File::open, IO::open 및 IO::read가 포함됩니다.

openread의 이러한 동작을 피하기 위해, 열려는 파일 이름의 시작을 공격자가 제어하지 못하도록 하는 것이 중요합니다. 예를 들어, 아래는 실수로 |로 시작하는 셸 명령어를 시작하는 것을 방지하는 데 충분합니다.

# 우리는 repo_path가 공격자(사용자)에 의해 제어되지 않았다고 가정합니다.
path = File.join(repo_path, user_input)
# 이제 path는 `|`로 시작할 수 없습니다.
File.read(path)

사용자 입력이 상대 경로를 사용해야 하는 경우, 경로에 ./를 접두어로 붙입니다.

사용자가 제공한 경로에 접두어를 붙이는 것은 -로 시작하는 경로에 대해 추가적인 보호 기능을 제공합니다(위의 -- 사용에 대한 토론을 참조하세요).

경로 트래버설에 대비하기

경로 트래버설은 프로그램(GitLab)이 디스크의 특정 디렉토리에 대한 사용자 액세스를 제한하려고 시도하지만 사용자가 ../ 경로 표기를 이용하여 해당 디렉토리 외부의 파일을 열어버리는 보안 이슈입니다.

# 사용자가 경로를 제공하고 우리를 속이려고 하는 경우
user_input = '../other-repo.git/other-file'

# 어딘가에서 저장소 경로를 찾아봅니다
repo_path = 'repositories/user-repo.git'

# 아래 코드의 의도는 repo_path 아래의 파일을 열려는 것이지만
# 사용자가 '..'를 사용하여 'repositories/other-repo.git'로 '탈출'할 수 있습니다.
full_path = File.join(repo_path, user_input)
File.open(full_path) do # 이런!

이를 방지하는 좋은 방법은 Ruby의 File.absolute_path에 따라 전체 경로와 ‘절대 경로’를 비교하는 것입니다.

full_path = File.join(repo_path, user_input)
if full_path != File.absolute_path(full_path)
  raise "잘못된 경로: #{full_path.inspect}"
end

File.open(full_path) do # 등.

이와 같은 확인은 CVE-2013-4583를 방지했을 수 있었습니다.

정규 표현식을 문자열의 시작과 끝에 고정시키기

쉘 명령에 전달되는 사용자 입력을 유효성 검사하기 위해 정규 표현식을 사용할 때, 문자열의 시작과 끝을 나타내는 \A\z 앵커를 사용하여 ^$ 또는 어떠한 앵커도 사용하지 말아야 합니다.

사용하지 않으면 공격자가 이를 사용하여 잠재적으로 유해한 효과를 가진 명령을 실행할 수 있습니다.

예를 들어, 프로젝트의 import_url이 아래와 같이 유효성을 검사하는 경우, 사용자는 로컬 파일 시스템의 Git 리포지토리로부터 GitLab을 속일 수 있습니다.

validates :import_url, format: { with: URI.regexp(%w(ssh git http https)) }
# URI.regexp(%w(ssh git http https))은 대략 /(ssh|git|http|https):(URL처럼 보이는_무언가)/로 평가됩니다

사용자가 다음과 같이 import URL로 제출하는 경우를 가정해봅시다.

file://git:/tmp/lol

사용된 정규 표현식에 앵커가 없기 때문에 값의 git:/tmp/lol이 일치하여 유효성 검사를 통과합니다.

GitLab이 가져올 때, 다음 명령을 실행하여 import_url을 인수로 전달합니다.

git clone file://git:/tmp/lol

Git은 git: 부분을 무시하고 경로를 file:///tmp/lol로 해석하고 새 프로젝트로 리포지토리를 가져옵니다. 이 작업은 공격자에게 시스템의 어떤 리포지토리든, 비공개든 상관없이 액세스 권한을 부여할 수 있습니다.