셸 명령어 개발 가이드라인

이 문서에는 GitLab 코드베이스에서 프로세스 및 파일을 다루는 가이드라인이 포함되어 있습니다. 이러한 가이드라인은 코드를 더 신뢰할 수 있고 안전하게 만드는 데 도움이 되도록 의도되었습니다.

참조

쉘 명령어 대신 File 및 FileUtils 사용

가끔 우리는 Unix 기본 명령어를 쉘을 통해 호출합니다. 그럴 때에도 루비 API가 있는 경우 루비 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)

# 가끔 쉘 명령어가 최선의 해결책일 수 있습니다. 아래 예제는 사용자 입력이 없으며 루비로 올바르게 구현하기 어려운 경우입니다: /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}))

쉘을 통해 명령어를 분리하여 우회하기

단일 문자열로 쉘 명령어를 루비에 전달할 때, 루비는 /bin/sh가 전체 문자열을 평가하도록 합니다. 본질적으로 우리는 쉘에게 한 줄 스크립트를 평가하도록 요청하고 있습니다. 이것은 쉘 인젝션 공격 가능성을 만듭니다. 쉘 명령어를 우리가 직접 토큰으로 나누는 것이 더 나은 방법입니다. 때로는 쉘의 스크립팅 기능을 사용하여 작업 디렉터리를 변경하거나 환경 변수를 설정합니다. 모두 이러한 작업은 루비에서 직접 안전하게 달성할 수 있습니다.

# 잘못된 예
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/93030 및 https://starlabs.sg/blog/2022/07-gitlab-project-import-rce-analysis-cve-2022-2185/ 또한 확인하세요.

옵션을 인수와 구분하기 위해 –를 사용

시스템 명령어의 인수 파서가 --로 옵션과 인수를 명확히 구분하도록하여 옵션/인수 모호함을 만들지 마세요. 대부분의 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가 포함됩니다.

‘open’ 및 ‘read’의 이러한 동작을 방지하려면 열고 있는 파일명의 시작을 악의적으로 제어할 수 없도록 보장해야 합니다. 예를 들어, 다음은 실수로 |로 시작하는 쉘이 실행되는 것을 방지하는 충분한 방법입니다.

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

사용자 입력으로 상대 경로를 사용해야 하는 경우 경로 앞에 ./를 붙입니다.

사용자가 제공한 경로에 접두사를 붙이면 -로 시작하는 경로에 대한 추가적인 보호가 제공됩니다 (위에서 -- 사용에 대한 설명 참조).

경로 경로 순회에 대한 보호

경로 경로 순회는 프로그램(GitLab)이 디스크의 특정 디렉터리에 대한 사용자 액세스를 제한하려고 시도하지만 사용자가 ../ 경로 표기법의 이점을 활용하여 해당 디렉터리 외부의 파일을 열어버리는 보안 취약성입니다.

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

# 어딘가에서 repo 경로를 찾습니다
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이 아래처럼 유효성 검사된 경우, 사용자는 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):(URL처럼 보이는_내용)/을 평가합니다.

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

file://git:/tmp/lol

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

Import 할 때, GitLab은 다음 명령을 실행하여 import_url을 인수로 전달합니다.

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

Git은 git: 부분을 무시하고 경로를 file:///tmp/lol로 해석하고 새 프로젝트에 리포지토리를 가져옵니다. 이 작업은 악의를 가진 사용자가 시스템의 어떤 리포지토리에 대한 액세스 권한을 제공할 수 있는 위험한 행동일 수 있습니다.