Node.js의 Brotli 디코더에서 버퍼 오버플로 취약점이 나왔다. 공식 Node.js 리눅스 바이너리는 PIE(Position-Independent Executable)가 아니다. 그래서 실행 파일의 코드 주소가 항상 같고, ASLR이 공격을 어렵게 만들지 못한다. glibc 개발자 아데메르발 자넬라 네토(Adhemerval Zanella Netto)는 이런 실행 파일을 동적 링커가 거부하게 하는 tunable 6개를 제안했다.
이 글에서는 제안된 tunable을 정리한다. 또한 실제 바이너리가 각 조건을 통과하는지 readelf로 검사한다. 실험 환경은 Ubuntu 24.04, glibc 2.39, GCC 13.3이다.
제안된 tunable
tunable은 환경 변수 GLIBC_TUNABLES나 /etc/tunables.conf로 설정한다. 기본값은 지금과 같고, 아래 값으로 바꾸면 보호가 켜진다.
| tunable | 거부하는 실행 파일 | readelf로 확인할 곳 |
|---|---|---|
glibc.rtld.require_pie=1 | PIE가 아닌 실행 파일 | -h의 Type: EXEC |
glibc.rtld.textrel=0 | 쓰기 불가 섹션에 재배치(TEXTREL)가 있는 파일 | -d의 TEXTREL |
glibc.rtld.wx_segments=0 | 쓰기와 실행이 함께 가능한 세그먼트가 있는 파일 | -l의 RWE |
glibc.rtld.bind_now=1 | 지연 바인딩을 끈다(거부는 아님) | -d의 BIND_NOW, FLAGS NOW |
glibc.rtld.relative_rpath=0 | 상대 경로 RPATH로 라이브러리 찾기 | -d의 RPATH, RUNPATH |
glibc.rtld.relro={1,2} | RELRO(1) 또는 RELRO와 -z now(2)가 없는 파일 | -l의 GNU_RELRO |
glibc.rtld.hardened=1 | 위 6개를 한 번에 켠다 | – |
glibc 2.39에는 아직 이 tunable이 없다.
$ /lib64/ld-linux-x86-64.so.2 --list-tunables | grep '^glibc.rtld'
glibc.rtld.dynamic_sort: 2 (min: 1, max: 2)
glibc.rtld.optional_static_tls: 0x200 (min: 0x0, max: 0xffffffffffffffff)
glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
PIE가 아니면 ASLR이 코드 주소를 바꾸지 못한다
같은 소스를 PIE와 non-PIE로 빌드해 main 주소를 세 번씩 찍는다.
#include <stdio.h>
int main(void) { printf("main=%p\n", (void *)main); return 0; }gcc -O2 -o hello-pie hello.c
gcc -O2 -no-pie -o hello-nopie hello.c
cat /proc/sys/kernel/randomize_va_space
for b in hello-pie hello-nopie; do echo "-- $b"; for i in 1 2 3; do ./$b; done; done2
-- hello-pie
main=0x60ff8806b060
main=0x63dc83bf0060
main=0x618696a2e060
-- hello-nopie
main=0x401050
main=0x401050
main=0x401050
ASLR이 켜져 있어도(2) non-PIE 실행 파일은 링크 주소 0x400000 근처에 고정된다.
공식 Node.js 바이너리와 Ubuntu 패키지 비교
nodejs.org의 v26.13.1 바이너리와 Ubuntu 24.04의 nodejs 패키지(18.19.1)를 비교한다.
curl -sSfLO https://nodejs.org/dist/v26.13.1/node-v26.13.1-linux-x64.tar.xz
tar xf node-v26.13.1-linux-x64.tar.xz node-v26.13.1-linux-x64/bin/node
cp node-v26.13.1-linux-x64/bin/node ./node-official
apt-get download nodejs && dpkg-deb -x nodejs_*.deb ub && cp ub/usr/bin/node ./node-ubuntu
for n in node-official node-ubuntu; do echo "-- $n"; readelf -hW $n | grep Type; done
for i in 1 2; do
./node-official -e 'console.log(require("fs").readFileSync("/proc/self/maps","utf8").split("\n")[0])'
done-- node-official
Type: EXEC (Executable file)
-- node-ubuntu
Type: DYN (Position-Independent Executable file)
00400000-00830000 r--p 00000000 08:02 9500707 /tmp/rtld-demo/node-official
00400000-00830000 r--p 00000000 08:02 9500707 /tmp/rtld-demo/node-official
공식 바이너리는 실행할 때마다 0x400000에 올라간다. Ubuntu 빌드는 PIE다. Ubuntu의 GCC가 --enable-default-pie로 빌드되어 있기 때문이다.
실행 파일 검사 스크립트
제안된 조건 6개를 readelf 출력으로 검사한다. 대문자 값(NO, YES)이 하드닝 값에서 거부되는 항목이다.
#!/bin/bash
# glibc 제안 tunable 기준으로 ELF 실행 파일을 검사한다
for f in "$@"; do
h=$(readelf -hW "$f"); l=$(readelf -lW "$f"); d=$(readelf -dW "$f" 2>/dev/null)
type=$(awk '/Type:/{print $2}' <<<"$h")
pie=$([ "$type" = DYN ] && echo yes || echo NO)
textrel=$(grep -qE 'TEXTREL|FLAGS.*TEXTREL' <<<"$d" && echo YES || echo no)
wx=$(awk '$1=="LOAD"||$1=="GNU_STACK"{if($(NF-1)~/WE|RWE/||$0~/RWE/)c++}END{print c?"YES":"no"}' <<<"$l")
now=$(grep -qE 'BIND_NOW|FLAGS.*NOW' <<<"$d" && echo yes || echo NO)
relro=$(grep -q GNU_RELRO <<<"$l" && echo yes || echo NO)
rp=$(grep -E 'R(UN)?PATH' <<<"$d" | sed -E 's/.*\[(.*)\]/\1/' | tr ':' '\n' | grep -vE '^/' | head -1)
printf "%-28s pie=%-3s textrel=%-3s wx=%-3s bind_now=%-3s relro=%-3s rel_rpath=%s\n" \
"$(basename "$f")" "$pie" "$textrel" "$wx" "$now" "$relro" "${rp:-none}"
done비교용 바이너리 두 개를 더 만든다. 하나는 지연 바인딩과 RELRO를 끈 것이고, 다른 하나는 실행 가능한 스택을 요구하는 것이다.
gcc -O2 -Wl,-z,lazy -Wl,-z,norelro -o hello-lazy hello.c
gcc -O2 -no-pie -z execstack -o hello-execstack hello.c
bash elfcheck.sh hello-pie hello-nopie hello-lazy hello-execstack \
node-official node-ubuntu /usr/bin/ls /usr/bin/python3.12hello-pie pie=yes textrel=no wx=no bind_now=yes relro=yes rel_rpath=none
hello-nopie pie=NO textrel=no wx=no bind_now=NO relro=yes rel_rpath=none
hello-lazy pie=yes textrel=no wx=no bind_now=NO relro=NO rel_rpath=none
hello-execstack pie=NO textrel=no wx=YES bind_now=NO relro=yes rel_rpath=none
node-official pie=NO textrel=no wx=no bind_now=yes relro=yes rel_rpath=none
node-ubuntu pie=yes textrel=no wx=no bind_now=yes relro=yes rel_rpath=$ORIGIN/../lib
ls pie=yes textrel=no wx=no bind_now=yes relro=yes rel_rpath=none
python3.12 pie=NO textrel=no wx=no bind_now=NO relro=yes rel_rpath=none
Ubuntu 24.04의 /usr/bin/python3.12도 PIE가 아니다. 실행해 보면 매번 0x400000에 올라간다.
$ readelf -hW /usr/bin/python3.12 | grep Type
Type: EXEC (Executable file)
$ dpkg -S /usr/bin/python3.12
python3.12-minimal: /usr/bin/python3.12
$ for i in 1 2; do python3.12 -c "print(open('/proc/self/maps').readline().rstrip())"; done
00400000-00420000 r--p 00000000 08:02 7864401 /usr/bin/python3.12
00400000-00420000 r--p 00000000 08:02 7864401 /usr/bin/python3.12
LWN 기사는 PIE를 기본으로 쓰는 배포판이라면 패키지 영향 없이 tunable을 켤 수 있다고 썼다. 하지만 제안대로라면 require_pie=1은 Ubuntu 24.04의 기본 Python 인터프리터부터 거부한다. Ubuntu의 nodejs는 $ORIGIN 기반 RUNPATH를 쓰므로 relative_rpath=0에도 걸릴 수 있다.
지금의 glibc 2.39는 모르는 tunable 이름을 무시한다. 그래서 같은 설정을 넣어도 non-PIE 파일은 그대로 실행된다.
$ GLIBC_TUNABLES=glibc.rtld.require_pie=1 ./hello-nopie; echo rc=$?
main=0x401050
rc=0
주의사항
- 시스템 전체에
glibc.rtld.hardened=1을 켜기 전에 검사 스크립트를 먼저 돌릴 것. 위 실험처럼 배포판 기본 패키지도 걸릴 수 있다. - 예외가 필요한 프로그램은 환경 변수로 해제할 것.
/etc/tunables.conf는 어떤 tunable을 환경 변수로 덮어쓸 수 있는지도 정한다. - tunable 이름을 지금 스크립트에 고정하지 말 것. 다음 패치 버전에서
allow_textrel처럼 긴 이름으로 바뀐다. relative_rpath=0과$ORIGIN의 관계는 확정된 뒤에 판단할 것. 댓글에서도$ORIGIN이 막히는지 의견이 갈렸다.- 직접 빌드하는 프로젝트는 PIE로 빌드할 것. x86-64의 PIE 비용은 RIP 상대 주소 덕분에 작다. Node.js PR의 측정값은 3~5%이고, 5~10%는 32비트 기준이었다.
마무리
제안된 tunable은 새 보호 기능을 만들지 않는다. 이미 있는 보호 기능을 쓰지 않은 실행 파일을 거부한다. 그래서 효과는 배포판과 업스트림이 PIE, RELRO, BIND_NOW로 빌드를 바꿀 때 나온다. 먼저 readelf로 자기 시스템의 바이너리부터 확인해 두는 것이 좋다.