셸 명령어 개발 가이드라인
GitLab v19.1이 문서에는 GitLab 코드베이스에서 프로세스와 파일을 다루기 위한 가이드라인이 포함되어 있습니다. Google Ruby Security Reviewer's Guide OWASP Command Injection Ruby on Rails Security Guide Command Line Injection
이 문서에는 GitLab 코드베이스에서 프로세스와 파일을 다루기 위한 가이드라인이 포함되어 있습니다. 이 가이드라인은 코드의 신뢰성과 보안을 높이기 위한 것입니다.
참고 자료#
셸 명령어 대신 File과 FileUtils를 사용하세요#
때로는 Ruby API가 존재함에도 불구하고 셸을 통해 기본적인 Unix 명령어를 실행하는 경우가 있습니다. Ruby API가 있다면 Ruby API를 사용하세요.
# Wrong
system "mkdir -p tmp/special/directory"
# Better (separate tokens)
system *%W(mkdir -p tmp/special/directory)
# Best (do not use a shell command)
FileUtils.mkdir_p "tmp/special/directory"
# Wrong
contents = `cat #{filename}`
# Correct
contents = File.read(filename)
# Sometimes a shell command is just the best solution. The example below has no
# user input, and is hard to implement correctly in Ruby: delete all files and
# directories older than 120 minutes under /some/path, but not /some/path
# itself.
Gitlab::Popen.popen(%W(find /some/path -not -path /some/path -mmin +120 -delete))
이 코딩 스타일은 CVE-2013-4490을 예방할 수 있었습니다.
Git 명령어에는 항상 설정 가능한 Git 바이너리 경로를 사용하세요#
# Wrong
system(*%W(git branch -d -- #{branch_name}))
# Correct
system(*%W(#{Gitlab.config.git.bin_path} branch -d -- #{branch_name}))
명령어를 별도 토큰으로 분리하여 셸 우회#
Ruby에 셸 명령어를 단일 문자열로 전달하면 Ruby는 /bin/sh가 전체 문자열을 평가하게 합니다. 이는 본질적으로 셸에 한 줄짜리 스크립트를 실행하도록 요청하는 것으로, 셸 인젝션 공격의 위험이 있습니다. 셸 명령어를 직접 토큰으로 분리하는 것이 더 안전합니다. 때로는 셸의 스크립트 기능을 사용하여 작업 디렉터리를 변경하거나 환경 변수를 설정하기도 합니다. 이러한 모든 작업은 Ruby에서 직접 안전하게 수행할 수 있습니다.
# Wrong
system "cd /home/git/gitlab && bundle exec rake db:#{something} RAILS_ENV=production"
# Correct
system({'RAILS_ENV' => 'production'}, *%W(bundle exec rake db:#{something}), chdir: '/home/git/gitlab')
# Wrong
system "touch #{myfile}"
# Better
system "touch", myfile
# Best (do not run a shell command at all)
FileUtils.touch myfile
이 코딩 스타일은 CVE-2013-4546을 예방할 수 있었습니다.
또 다른 예시로 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93030과 https://starlabs.sg/blog/2022/07-gitlab-project-import-rce-analysis-cve-2022-2185/도 참고하세요.
--로 옵션과 인수를 구분하세요#
시스템 명령어의 인수 파서에게 옵션과 인수의 차이를 --로 명확히 알려주세요. 이 기능은 많은 Unix 명령어에서 지원되지만 모두 지원하는 것은 아닙니다.
--가 무엇을 하는지 이해하려면 아래의 문제를 살펴보세요.
# Example
$ echo hello > -l
$ cat -l
cat: illegal option -- l
usage: cat [-benstuv] [file ...]
위 예시에서 cat의 인수 파서는 -l을 옵션으로 간주합니다. 해결책은 cat에게 -l이 실제로 인수이지 옵션이 아님을 명확히 알려주는 것입니다. 많은 Unix 커맨드라인 도구는 --로 옵션과 인수를 구분하는 관례를 따릅니다.
# Example (continued)
$ cat -- -l
hello
GitLab 코드베이스에서는 이를 지원하는 명령어에 대해 항상 --를 사용하여 옵션/인수 모호성을 피합니다.
# Wrong
system(*%W(#{Gitlab.config.git.bin_path} branch -d #{branch_name}))
# Correct
system(*%W(#{Gitlab.config.git.bin_path} branch -d -- #{branch_name}))
이 코딩 스타일은 CVE-2013-4582를 예방할 수 있었습니다.
백틱(backtick)을 사용하지 마세요#
백틱으로 셸 명령어의 출력을 캡처하면 코드가 읽기 좋아 보이지만, 명령어를 셸에 단일 문자열로 전달해야 합니다. 앞서 설명한 것처럼 이는 안전하지 않습니다. 메인 GitLab 코드베이스에서는 대신 Gitlab::Popen.popen을 사용합니다.
# Wrong
logs = `cd #{repo_dir} && #{Gitlab.config.git.bin_path} log`
# Correct
logs, exit_status = Gitlab::Popen.popen(%W(#{Gitlab.config.git.bin_path} log), repo_dir)
# Wrong
user = `whoami`
# Correct
user, exit_status = Gitlab::Popen.popen(%W(whoami))
GitLab Shell과 같은 다른 리포지터리에서는 IO.popen도 사용할 수 있습니다.
# Safe IO.popen example
logs = IO.popen(%W(#{Gitlab.config.git.bin_path} log), chdir: repo_dir) { |p| p.read }
Gitlab::Popen.popen과 달리 IO.popen은 표준 오류를 캡처하지 않는다는 점에 유의하세요.
경로 문자열의 시작에 사용자 입력을 피하세요#
Ruby에서 파일을 열고 읽는 다양한 메서드는 파일 대신 프로세스의 표준 출력을 읽는 데 사용될 수 있습니다. 다음 두 명령어는 거의 동일한 작업을 수행합니다:
`touch /tmp/pawned-by-backticks`
File.read('|touch /tmp/pawned-by-file-read')
핵심은 이름이 |로 시작하는 '파일'을 여는 것입니다.
영향을 받는 메서드에는 Kernel#open, File::read, File::open, IO::open, IO::read가 포함됩니다.
'open'과 'read'의 이 동작으로부터 보호하려면 열고자 하는 파일명 문자열의 시작을 공격자가 제어할 수 없도록 해야 합니다. 예를 들어, 다음과 같은 방법으로 |로 셸 명령어가 시작되는 것을 우발적으로 방지하기에 충분합니다:
# we assume repo_path is not controlled by the attacker (user)
path = File.join(repo_path, user_input)
# path cannot start with '|' now.
File.read(path)
사용자 입력을 상대 경로로 사용해야 하는 경우 경로 앞에 ./를 추가하세요.
사용자가 제공한 경로에 ./를 접두사로 붙이면 -로 시작하는 경로에 대한 추가 보호도 제공됩니다(위의 -- 사용에 관한 내용 참조).
경로 트래버설(path traversal)에 대비하세요#
경로 트래버설은 프로그램(GitLab)이 디스크의 특정 디렉터리로 사용자 접근을 제한하려 하지만, 사용자가 ../ 경로 표기법을 이용하여 해당 디렉터리 외부의 파일을 열 수 있게 되는 보안 취약점입니다.
# Suppose the user gave us a path and they are trying to trick us
user_input = '../other-repo.git/other-file'
# We look up the repo path somewhere
repo_path = 'repositories/user-repo.git'
# The intention of the code below is to open a file under repo_path, but
# because the user used '..' they can 'break out' into
# 'repositories/other-repo.git'
full_path = File.join(repo_path, user_input)
File.open(full_path) do # Oops!
이에 대한 좋은 방어 방법은 전체 경로를 Ruby의 File.absolute_path에 따른 '절대 경로'와 비교하는 것입니다.
full_path = File.join(repo_path, user_input)
if full_path != File.absolute_path(full_path)
raise "Invalid path: #{full_path.inspect}"
end
File.open(full_path) do # Etc.
이와 같은 검사는 CVE-2013-4583을 방지할 수 있었습니다.
정규 표현식을 문자열의 시작과 끝에 올바르게 고정하세요#
셸 명령어에 인수로 전달되는 사용자 입력을 검증하는 데 정규 표현식을 사용할 때는 ^와 $ 또는 앵커 없이 사용하는 대신, 문자열의 시작과 끝을 지정하는 \A와 \z 앵커를 사용해야 합니다.
그렇지 않으면 공격자가 이를 이용하여 잠재적으로 해로운 명령어를 실행할 수 있습니다.
예를 들어, 프로젝트의 import_url이 아래와 같이 검증되는 경우 사용자는 GitLab을 속여 로컬 파일 시스템의 Git 리포지터리에서 클론하도록 만들 수 있습니다.
validates :import_url, format: { with: URI.regexp(%w(ssh git http https)) }
# URI.regexp(%w(ssh git http https)) roughly evaluates to /(ssh|git|http|https):(something_that_looks_like_a_url)/
사용자가 다음을 import URL로 제출한다고 가정해 보세요:
file://git:/tmp/lol
사용된 정규 표현식에 앵커가 없기 때문에 값 내의 git:/tmp/lol이 일치하여 검증을 통과합니다.
가져오기 시 GitLab은 import_url을 인수로 전달하여 다음 명령어를 실행합니다:
git clone file://git:/tmp/lol
Git은 git: 부분을 무시하고 경로를 file:///tmp/lol로 해석하여 새 프로젝트로 리포지터리를 가져옵니다. 이 작업은 공격자에게 비공개 리포지터리를 포함한 시스템 내 모든 리포지터리에 대한 접근 권한을 잠재적으로 부여할 수 있습니다.