InfoGrab DocsInfoGrab Docs

셸 명령어 개발 가이드라인

요약

이 문서에는 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/93030https://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로 해석하여 새 프로젝트로 리포지터리를 가져옵니다. 이 작업은 공격자에게 비공개 리포지터리를 포함한 시스템 내 모든 리포지터리에 대한 접근 권한을 잠재적으로 부여할 수 있습니다.

셸 명령어 개발 가이드라인

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/93030https://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로 해석하여 새 프로젝트로 리포지터리를 가져옵니다. 이 작업은 공격자에게 비공개 리포지터리를 포함한 시스템 내 모든 리포지터리에 대한 접근 권한을 잠재적으로 부여할 수 있습니다.