셸 명령어 개발 가이드라인

이 문서에는 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 하위에서 /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을 방지할 수 있었을 것입니다.

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93030https://starlabs.sg/blog/2022/07-gitlab-project-import-rce-analysis-cve-2022-2185/를 참조하세요.

–로 옵션과 인수를 구분하세요

시스템 명령어의 인수 분석기에게 옵션과 인수 사이의 차이를 명확히 하려면 --를 사용하세요. 이 기능은 많은 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은 표준 오류를 캡처하지 않습니다.

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

루비에서 파일을 열고 읽는 다양한 방법을 사용하여 파일의 표준 출력을 읽는 대신 프로세스의 표준 출력을 읽을 수 있습니다. 다음 두 명령어는 대략적으로 동일합니다.

`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 # 어이쿠!

이에 대한 좋은 방법은 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이 일치하여 유효성이 통과될 것입니다.

GitLab이 가져올 때, import_url을 인자로 전달하면 다음 명령이 실행될 것입니다:

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

Git은 git: 부분을 무시하고, 경로를 file:///tmp/lol로 해석하여 새 프로젝트로 리포지터리를 가져옵니다. 이 행동은 공격자에게 시스템 내의 비공개이건 공개이건 모든 리포지터리에 대한 액세스를 제공할 수 있습니다.