Perl 보안 패턴
Perl 애플리케이션을 위한 포괄적인 보안 가이드라인입니다. 입력 검증, 인젝션 방지, 안전한 코딩 관행을 다룹니다.
활성화 시점
- Perl 애플리케이션에서 사용자 입력을 처리할 때
- Perl 웹 애플리케이션(CGI, Mojolicious, Dancer2, Catalyst)을 구축할 때
- Perl 코드의 보안 취약점을 검토할 때
- 사용자가 제공한 경로로 파일 작업을 수행할 때
- Perl에서 시스템 명령어를 실행할 때
- DBI 데이터베이스 쿼리를 작성할 때
작동 방식
테인트 인식 입력 경계에서 시작하여 외부로 확장합니다. 입력을 검증하고 언테인트(untaint)하며, 파일 시스템과 프로세스 실행을 제한된 범위 내에서 유지하고, 모든 곳에서 매개변수화된 DBI 쿼리를 사용합니다. 아래 예제는 이 스킬이 사용자 입력, 셸 또는 네트워크를 다루는 Perl 코드를 배포하기 전에 적용해야 하는 안전한 기본값을 보여줍니다.
테인트 모드
Perl의 테인트 모드(-T)는 외부 소스의 데이터를 추적하여 명시적 검증 없이 안전하지 않은 작업에 사용되지 않도록 방지합니다.
테인트 모드 활성화
#!/usr/bin/perl -T
use v5.36;
# 테인트됨: 프로그램 외부에서 온 모든 것
my $input = $ARGV[0]; # 테인트됨
my $env_path = $ENV{PATH}; # 테인트됨
my $form = <STDIN>; # 테인트됨
my $query = $ENV{QUERY_STRING}; # 테인트됨
# PATH를 일찍 정리(테인트 모드에서 필수)
$ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
언테인트 패턴
use v5.36;
# 좋음: 특정 정규식으로 검증하고 언테인트
sub untaint_username($input) {
if ($input =~ /^([a-zA-Z0-9_]{3,30})$/) {
return $1; # $1은 언테인트됨
}
die "올바르지 않은 사용자 이름: 3-30자의 영숫자여야 합니다\n";
}
# 좋음: 파일 경로 검증 및 언테인트
sub untaint_filename($input) {
if ($input =~ m{^([a-zA-Z0-9._-]+)$}) {
return $1;
}
die "올바르지 않은 파일 이름: 안전하지 않은 문자가 포함됨\n";
}
# 나쁨: 지나치게 관대한 언테인트(목적을 무력화)
sub bad_untaint($input) {
$input =~ /^(.*)$/s;
return $1; # 어떤 것이든 허용 — 무의미함
}
입력 검증
블록리스트보다는 허용 목록
use v5.36;
# 좋음: 허용 목록 — 정확히 허용되는 것을 정의
sub validate_sort_field($field) {
my %allowed = map { $_ => 1 } qw(name email created_at updated_at);
die "올바르지 않은 정렬 필드: $field\n" unless $allowed{$field};
return $field;
}
# 좋음: 특정 패턴으로 검증
sub validate_email($email) {
if ($email =~ /^([a-zA-Z0-9._%+-]+\@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/) {
return $1;
}
die "올바르지 않은 이메일 주소\n";
}
sub validate_integer($input) {
if ($input =~ /^(-?\d{1,10})$/) {
return $1 + 0; # 숫자로 강제 변환
}
die "올바르지 않은 정수\n";
}
# 나쁨: 블록리스트 — 항상 불완전함
sub bad_validate($input) {
die "올바르지 않음" if $input =~ /[<>"';&|]/; # 인코딩된 공격을 놓침
return $input;
}
길이 제약
use v5.36;
sub validate_comment($text) {
die "댓글은 필수입니다\n" unless length($text) > 0;
die "댓글이 10000자를 초과했습니다\n" if length($text) > 10_000;
return $text;
}
안전한 정규 표현식
ReDoS 방지
중첩된 수량자가 중복 패턴과 함께 사용될 때 치명적인 역추적이 발생합니다.
use v5.36;
# 나쁨: ReDoS에 취약함(지수적 역추적)
my $bad_re = qr/^(a+)+$/; # 중첩된 수량자
my $bad_re2 = qr/^([a-zA-Z]+)*$/; # 클래스의 중첩된 수량자
my $bad_re3 = qr/^(.*?,){10,}$/; # 반복되는 탐욕/비탐욕 조합
# 좋음: 중첩 없이 다시 작성
my $good_re = qr/^a+$/; # 단일 수량자
my $good_re2 = qr/^[a-zA-Z]+$/; # 클래스의 단일 수량자
# 좋음: 소유 수량자 또는 원자 그룹을 사용하여 역추적 방지
my $safe_re = qr/^[a-zA-Z]++$/; # 소유 (5.10+)
my $safe_re2 = qr/^(?>a+)$/; # 원자 그룹
# 좋음: 신뢰할 수 없는 패턴에 시간 제한 적용
use POSIX qw(alarm);
sub safe_match($string, $pattern, $timeout = 2) {
my $matched;
eval {
local $SIG{ALRM} = sub { die "정규식 시간 초과\n" };
alarm($timeout);
$matched = $string =~ $pattern;
alarm(0);
};
alarm(0);
die $@ if $@;
return $matched;
}
안전한 파일 작업
세 인수 열기
use v5.36;
# 좋음: 세 인수 열기, 렉시컬 파일 핸들, 반환값 확인
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path
or die "'$path'을(를) 열 수 없음: $!\n";
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
# 나쁨: 사용자 데이터로 두 인수 열기(명령어 인젝션)
sub bad_read($path) {
open my $fh, $path; # $path = "|rm -rf /"이면 명령어 실행!
open my $fh, "< $path"; # 셸 메타문자 인젝션
}
TOCTOU 방지 및 경로 탐색
use v5.36;
use Fcntl qw(:DEFAULT :flock);
use File::Spec;
use Cwd qw(realpath);
# 원자적 파일 생성
sub create_file_safe($path) {
sysopen(my $fh, $path, O_WRONLY | O_CREAT | O_EXCL, 0600)
or die "'$path'을(를) 생성할 수 없음: $!\n";
return $fh;
}
# 경로가 허용된 디렉터리 내에 머무르는지 검증
sub safe_path($base_dir, $user_path) {
my $real = realpath(File::Spec->catfile($base_dir, $user_path))
// die "경로가 존재하지 않음\n";
my $base_real = realpath($base_dir)
// die "기본 디렉터리가 존재하지 않음\n";
die "경로 탐색이 차단됨\n" unless $real =~ /^\Q$base_real\E(?:\/|\z)/;
return $real;
}
임시 파일에는 File::Temp(tempfile(UNLINK => 1))를, 경쟁 상태 방지에는 flock(LOCK_EX)를 사용하세요.
안전한 프로세스 실행
리스트 형태의 system 및 exec
use v5.36;
# 좋음: 리스트 형태 — 셸 보간 없음
sub run_command(@cmd) {
system(@cmd) == 0
or die "명령어 실패: @cmd\n";
}
run_command('grep', '-r', $user_pattern, '/var/log/app/');
# 좋음: IPC::Run3로 출력을 안전하게 캡처
use IPC::Run3;
sub capture_output(@cmd) {
my ($stdout, $stderr);
run3(\@cmd, \undef, \$stdout, \$stderr);
if ($?) {
die "명령어 실패 (종료 코드 $?): $stderr\n";
}
return $stdout;
}
# 나쁨: 문자열 형태 — 셸 인젝션!
sub bad_search($pattern) {
system("grep -r '$pattern' /var/log/app/"); # $pattern = "'; rm -rf / #"이면...
}
# 나쁨: 보간과 함께 백틱 사용
my $output = `ls $user_dir`; # 셸 인젝션 위험
외부 명령어에서 stdout/stderr를 안전하게 캡처하려면 Capture::Tiny도 사용하세요.
SQL 인젝션 방지
DBI 플레이스홀더
use v5.36;
use DBI;
my $dbh = DBI->connect($dsn, $user, $pass, {
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
});
# 좋음: 매개변수화된 쿼리 — 항상 플레이스홀더 사용
sub find_user($dbh, $email) {
my $sth = $dbh->prepare('SELECT * FROM users WHERE email = ?');
$sth->execute($email);
return $sth->fetchrow_hashref;
}
sub search_users($dbh, $name, $status) {
my $sth = $dbh->prepare(
'SELECT * FROM users WHERE name LIKE ? AND status = ? ORDER BY name'
);
$sth->execute("%$name%", $status);
return $sth->fetchall_arrayref({});
}
# 나쁨: SQL에서 문자열 보간 (SQLi 취약점!)
sub bad_find($dbh, $email) {
my $sth = $dbh->prepare("SELECT * FROM users WHERE email = '$email'");
# $email = "' OR 1=1 --"이면 모든 사용자 반환
$sth->execute;
return $sth->fetchrow_hashref;
}
동적 컬럼 허용 목록
use v5.36;
# 좋음: 허용 목록에 대해 컬럼 이름 검증
sub order_by($dbh, $column, $direction) {
my %allowed_cols = map { $_ => 1 } qw(name email created_at);
my %allowed_dirs = map { $_ => 1 } qw(ASC DESC);
die "올바르지 않은 컬럼: $column\n" unless $allowed_cols{$column};
die "올바르지 않은 방향: $direction\n" unless $allowed_dirs{uc $direction};
my $sth = $dbh->prepare("SELECT * FROM users ORDER BY $column $direction");
$sth->execute;
return $sth->fetchall_arrayref({});
}
# 나쁨: 사용자가 선택한 컬럼을 직접 보간
sub bad_order($dbh, $column) {
$dbh->prepare("SELECT * FROM users ORDER BY $column"); # SQLi!
}
DBIx::Class (ORM 안전성)
use v5.36;
# DBIx::Class는 안전한 매개변수화된 쿼리를 생성합니다
my @users = $schema->resultset('User')->search({
status => 'active',
email => { -like => '%@example.com' },
}, {
order_by => { -asc => 'name' },
rows => 50,
});
웹 보안
XSS 방지
use v5.36;
use HTML::Entities qw(encode_entities);
use URI::Escape qw(uri_escape_utf8);
# 좋음: HTML 컨텍스트에 맞게 출력 인코딩
sub safe_html($user_input) {
return encode_entities($user_input);
}
# 좋음: URL 컨텍스트에 맞게 인코딩
sub safe_url_param($value) {
return uri_escape_utf8($value);
}
# 좋음: JSON 컨텍스트에 맞게 인코딩
use JSON::MaybeXS qw(encode_json);
sub safe_json($data) {
return encode_json($data); # 이스케이프 처리
}
# 템플릿 자동 이스케이프 (Mojolicious)
# <%= $user_input %> — 자동 이스케이프 (안전)
# <%== $raw_html %> — 원시 출력 (위험, 신뢰할 수 있는 콘텐츠에만 사용)
# 템플릿 자동 이스케이프 (Template Toolkit)
# [% user_input | html %] — 명시적 HTML 인코딩
# 나쁨: HTML에서 원시 출력
sub bad_html($input) {
print "<div>$input</div>"; # $input에 <script>가 포함된 경우 XSS
}
CSRF 보호
use v5.36;
use Crypt::URandom qw(urandom);
use MIME::Base64 qw(encode_base64url);
sub generate_csrf_token() {
return encode_base64url(urandom(32));
}
토큰을 확인할 때는 상수 시간 비교를 사용하세요. 대부분의 웹 프레임워크(Mojolicious, Dancer2, Catalyst)는 내장 CSRF 보호 기능을 제공하므로 직접 구현하는 것보다는 이를 선호하세요.
세션 및 헤더 보안
use v5.36;
# Mojolicious 세션 + 헤더
$app->secrets(['길고-무작위인-비밀-정기적으로-교체']);
$app->sessions->secure(1); # HTTPS 전용
$app->sessions->samesite('Lax');
$app->hook(after_dispatch => sub ($c) {
$c->res->headers->header('X-Content-Type-Options' => 'nosniff');
$c->res->headers->header('X-Frame-Options' => 'DENY');
$c->res->headers->header('Content-Security-Policy' => "default-src 'self'");
$c->res->headers->header('Strict-Transport-Security' => 'max-age=31536000; includeSubDomains');
});
출력 인코딩
항상 컨텍스트에 맞게 출력을 인코딩하세요: HTML에는 HTML::Entities::encode_entities(), URL에는 URI::Escape::uri_escape_utf8(), JSON에는 JSON::MaybeXS::encode_json().
CPAN 모듈 보안
- 버전 고정: cpanfile에
requires 'DBI', '== 1.643'; - 유지 관리되는 모듈 선호: MetaCPAN에서 최근 릴리즈 확인
- 의존성 최소화: 각 의존성은 공격 표면입니다
보안 도구
perlcritic 보안 정책
# .perlcriticrc — 보안 중심 구성
severity = 3
theme = security + core
# 세 인수 열기 요구
[InputOutput::RequireThreeArgOpen]
severity = 5
# 확인된 시스템 호출 요구
[InputOutput::RequireCheckedSyscalls]
functions = :builtins
severity = 4
# 문자열 eval 금지
[BuiltinFunctions::ProhibitStringyEval]
severity = 5
# 백틱 연산자 금지
[InputOutput::ProhibitBacktickOperators]
severity = 4
# CGI에서 테인트 검사 요구
[Modules::RequireTaintChecking]
severity = 5
# 두 인수 열기 금지
[InputOutput::ProhibitTwoArgOpen]
severity = 5
# 베어워드 파일 핸들 금지
[InputOutput::ProhibitBarewordFileHandles]
severity = 5
perlcritic 실행
# 파일 확인
perlcritic --severity 3 --theme security lib/MyApp/Handler.pm
# 전체 프로젝트 확인
perlcritic --severity 3 --theme security lib/
# CI 통합
perlcritic --severity 4 --theme security --quiet lib/ || exit 1
빠른 보안 체크리스트
| 확인 항목 | 검증 내용 |
|---|---|
| 테인트 모드 | CGI/웹 스크립트에 -T 플래그 |
| 입력 검증 | 허용 목록 패턴, 길이 제한 |
| 파일 작업 | 세 인수 열기, 경로 탐색 검사 |
| 프로세스 실행 | 리스트 형태의 system, 셸 보간 없음 |
| SQL 쿼리 | DBI 플레이스홀더, 절대 보간 금지 |
| HTML 출력 | encode_entities(), 템플릿 자동 이스케이프 |
| CSRF 토큰 | 생성, 상태 변경 요청 시 확인 |
| 세션 구성 | Secure, HttpOnly, SameSite 쿠키 |
| HTTP 헤더 | CSP, X-Frame-Options, HSTS |
| 의존성 | 버전 고정, 감사된 모듈 |
| 정규식 안전성 | 중첩 수량자 없음, 앵커된 패턴 |
| 오류 메시지 | 사용자에게 스택 트레이스나 경로 누출 금지 |
안티 패턴
# 1. 사용자 데이터로 두 인수 열기 (명령어 인젝션)
open my $fh, $user_input; # 심각한 취약점
# 2. 문자열 형태의 system (셸 인젝션)
system("convert $user_file output.png"); # 심각한 취약점
# 3. SQL 문자열 보간
$dbh->do("DELETE FROM users WHERE id = $id"); # SQLi
# 4. 사용자 입력으로 eval (코드 인젝션)
eval $user_code; # 원격 코드 실행
# 5. 정리 없이 $ENV 신뢰
my $path = $ENV{UPLOAD_DIR}; # 조작 가능
system("ls $path"); # 이중 취약점
# 6. 검증 없이 테인트 비활성화
($input) = $input =~ /(.*)/s; # 게으른 언테인트 — 목적 무력화
# 7. HTML에 원시 사용자 데이터
print "<div>Welcome, $username!</div>"; # XSS
# 8. 검증되지 않은 리디렉션
print $cgi->redirect($user_url); # 오픈 리디렉션
기억하세요: Perl의 유연성은 강력하지만 규율이 필요합니다. 웹에 노출되는 코드에는 테인트 모드를 사용하고, 모든 입력을 허용 목록으로 검증하며, 모든 쿼리에 DBI 플레이스홀더를 사용하고, 모든 출력을 컨텍스트에 맞게 인코딩하세요. 심층 방어(Defense in depth) — 단일 계층에 의존하지 마십시오.