셸 명령어 개발 가이드라인

이 문서는 GitLab 코드베이스에서 프로세스와 파일 작업에 대한 가이드라인을 포함합니다.

이 가이드라인은 여러분의 코드를 더욱 신뢰할 수 있고 안전 하도록 만드는 것을 목표로 합니다.

참조

셸 명령 대신 File 및 FileUtils 사용

때때로 Ruby API가 존재할 때 기본 Unix 명령을 셸을 통해 호출합니다. 존재할 경우 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 아래의
# 120분보다 오래된 모든 파일과 디렉토리를 삭제하되 /some/path 자체는 제외합니다.
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을 방지할 수 있었습니다.

자세한 사항은 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93030https://starlabs.sg/blog/2022/07-gitlab-project-import-rce-analysis-cve-2022-2185/에서 다른 예제를 확인하세요.

--로 옵션과 인수를 분리

시스템 명령어의 인수 파서에게 옵션과 인수의 차이를 명확히 하려면 --를 사용하세요. 이는 많은 Unix 명령어에서 지원되지만 모든 Unix 명령어에서 지원되는 것은 아닙니다.

--의 기능을 이해하기 위해 아래 문제를 고려해 보세요.

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

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

위의 예에서 cat의 인수 파서는 -l이 옵션이라고 가정합니다. 위의 예에서 해결방법은 cat에게 -l이 정말로 옵션이 아니라 인수임을 명확히 하는 것입니다. 많은 Unix 명령행 도구는 옵션과 인수를 --로 구분하는 규칙을 따릅니다.

# 예시 (계속)
$ cat -- -l

hello

GitLab 코드베이스에서는 항상 지원되는 명령어에 대해 --를 사용하여 옵션/인수 모호성을 피합니다.

# 잘못된 예
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은 표준 오류를 캡처하지 않는다는 점에 주의하세요.

경로 문자열 시작에서 사용자 입력 피하기

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’의 이러한 동작을 방지하려면 공격자가 여는 파일 이름 문자열의 시작을 제어할 수 없도록 해야 합니다. 예를 들어, 다음과 같은 방법으로 |로 시작하는 셸 명령을 우연히 시작하는 것을 방지할 수 있습니다.

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

사용자 입력에 상대 경로를 사용해야 하는 경우, 경로에 ./를 접두어로 추가하세요.

제공된 경로에 접두어를 붙이는 것은 -로 시작하는 경로에 대한 추가 보호를 제공하기도 합니다(위에서 -- 사용에 대한 논의 참조).

경로 침해 방지

경로 침해는 프로그램(GitLab)이 디스크의 특정 디렉터리에 대한 사용자 접근을 제한하려고 하지만, 사용자가 ../ 경로 표기법을 이용하여 해당 디렉터리 바깥의 파일을 열 수 있는 경우 발생하는 보안 문제입니다.

# 사용자가 경로를 제공했으며 우리를 속이려 하고 있습니다.
user_input = '../other-repo.git/other-file'

# 우리는 어딘가에서 repo_path를 조회합니다.
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 # 오ops!

이를 방지하는 좋은 방법은 전체 경로를 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이 아래와 같이 유효성 검사를 받을 때 사용자가 GitLab을 속여 로컬 파일 시스템의 Git 저장소에서 클론하도록 만들 수 있습니다.

validates :import_url, format: { with: URI.regexp(%w(ssh git http https)) }
# URI.regexp(%w(ssh git http https))는 대략 /(ssh|git|http|https):(something_that_looks_like_a_url)/로 평가됩니다.

사용자가 다음을 자신의 가져오기 URL로 제출했다고 가정해 보겠습니다:

file://git:/tmp/lol

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

가져올 때, GitLab은 다음 명령을 실행하며 import_url을 인수로 전달합니다:

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

Git은 git: 부분을 무시하고 경로를 file:///tmp/lol로 해석하며, 새 프로젝트에 저장소를 가져옵니다. 이 행동은 공격자가 시스템의 모든 저장소에 접근할 수 있게 할 수 있으며, 비공개 저장소일 수도 있습니다.