nf_tables 바인딩된(bound) 체인에 NFTA_RULE_CHAIN_ID로 규칙을 몰래 추가 — 소유권 불변식 파괴로 인한 Use-After-Free (CVE-2023-4147)
| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2023-4147 |
| 영향 버전 | Linux 커널 (바인딩 체인 기능이 도입된 5.19 이후 ~ 아래 수정 커밋 반영 전) |
| CVSS v3.1 | 7.8 (High) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-416 (Use After Free) |
| 공개일 | 2023-08-07 (NVD) |
| 보고자 | Kevin Rich |
| 수정 커밋 | 0ebc1064e4874d5987722a2ddbc18f94aa53b211 ("netfilter: nf_tables: disallow rule addition to bound chain via NFTA_RULE_CHAIN_ID"), Pablo Neira Ayuso / Florian Westphal |
| 원인이 된 기능 도입 커밋 | d0e2c7de92c7 ("netfilter: nf_tables: add NFT_CHAIN_BINDING") |
nf_tables(nft)는 룰셋을 넷링크 배치(batch) 트랜잭션으로 원자적으로 적용하는데, 이때
"바인딩된(bound) 체인" — 익명으로 생성되어 그 체인을 참조하는 단 하나의 jump/goto
규칙에게만 배타적으로 소유되는 체인 — 이라는 개념이 있다. 바인딩된 체인은 사용자가
직접 이름으로 조작할 수 없고, 오직 그 체인을 만든 "소유자" 규칙이 삭제될 때 함께
자동으로 정리(destroy)되도록 설계돼 있다. 이 배타적 소유권을 지키기 위해
nf_tables_newrule()(새 규칙 추가 API)은 대상 체인이 바인딩된 체인이면 요청을
거부하는 검사(nft_chain_is_bound())를 두고 있었다. 문제는 이 검사가 체인을
이름(NFTA_RULE_CHAIN)으로 지정했을 때만 적용되고, 같은 트랜잭션 안에서 방금
만든 체인을 트랜잭션 로컬 ID(NFTA_RULE_CHAIN_ID)로 지정했을 때는 빠져 있었다는
점이다. 공격자는 이 우회 경로로 바인딩된 체인에 규칙을 몰래 추가할 수 있었고, 이는
"바인딩된 체인은 소유자가 지운 규칙 수만큼만 정확히 갖고 있다"는 소멸(destroy) 경로의
암묵적 불변식을 깨뜨려 use-after-free로 이어졌다.
nft 명령은 룰셋 변경을 하나의 넷링크 배치 메시지에 담아 커널에 원자적으로 적용한다.
예를 들어 "새 익명 체인을 만들고, 그 체인으로 점프하는 규칙을 기존 체인에 추가하고,
그 새 체인 안에 실제 필터링 규칙들을 채워 넣는다"는 세 단계가 한 배치 안에서
한꺼번에 일어난다. 이런 익명 체인은 map/vmap(verdict map) 같은 고수준 nftables
구문을 컴파일한 결과로 흔히 생성되며, NFT_CHAIN_BINDING 플래그를 달고 태어난다.
NFT_CHAIN_BINDING 플래그가 붙은 체인은 커널이 "이 체인은 딱 하나의 immediate
(jump/goto) 표현식에만 종속된다"는 계약을 강제한다 — 그 표현식이 사라지면 체인도
함께 사라져야 하고, 사용자가 별도로 그 체인을 이름으로 찾아 규칙을 추가하거나 지우는
것은 금지된다. 이 계약은 커널 헤더의 실제 정의에 그대로 반영돼 있다
(include/net/netfilter/nf_tables.h).
struct nft_chain {
...
u32 use; /* 이 체인을 참조/포함하는 항목 수 */
u8 flags:5,
bound:1, /* 이미 소유자에게 "묶였는지" 여부 */
genmask:2;
...
};
static inline bool nft_chain_binding(const struct nft_chain *chain)
{
return chain->flags & NFT_CHAIN_BINDING;
}
static inline bool nft_chain_is_bound(struct nft_chain *chain)
{
return (chain->flags & NFT_CHAIN_BINDING) && chain->bound;
}flags & NFT_CHAIN_BINDING은 "이 체인이 바인딩 가능한 종류인가"를, bound 비트는
"실제로 이미 누군가에게 묶였는가"를 나타낸다. 체인이 갓 생성된 시점에는
NFT_CHAIN_BINDING은 켜져 있지만 bound는 아직 꺼져 있고, jump/goto 표현식이
그 체인을 verdict 대상으로 초기화할 때 다음과 같이 묶인다
(nf_tables_api.c, nf_tables_bind_chain()).
int nf_tables_bind_chain(const struct nft_ctx *ctx, struct nft_chain *chain)
{
if (!nft_chain_binding(chain))
return 0;
if (nft_chain_binding(ctx->chain))
return -EOPNOTSUPP;
if (chain->bound)
return -EBUSY; /* 이미 다른 규칙이 선점 — 재바인딩 금지 */
chain->bound = true;
chain->use++; /* 소유자 immediate 표현식의 참조를 카운트 */
nft_chain_trans_bind(ctx, chain);
return 0;
}chain->use는 "지금 이 체인을 필요로 하는 것이 몇 개인가"를 세는 참조 카운터다.
바인딩 성사 시 use++로 소유자 한 개를 반영하고, 이후 그 바인딩된 체인 안에
규칙이 추가될 때마다(chain->rules 리스트에 rule이 붙을 때마다) 일반적인 규칙
추가 경로에서 다시 use가 증가한다. 요컨대 chain->use는 "소유자 1개 + 그 안에
든 규칙 개수"와 정확히 일치해야 하는 값이다.
jump/goto verdict를 담은 immediate 표현식이 삭제되면(nft_immediate.c), 그
표현식이 소유한 바인딩된 체인 전체를 함께 정리한다.
/* nft_immediate_destroy(), 요약 */
chain = data->verdict.chain;
if (!nft_chain_binding(chain))
return; /* 바인딩된 체인이 아니면 상관없음 */
chain_ctx = *ctx;
chain_ctx.chain = chain;
chain->use--; /* 소유자 몫 1 감소 */
list_for_each_entry_safe(rule, n, &chain->rules, list) {
chain->use--; /* rules 리스트에 실제로 걸려 있는 규칙 수만큼 감소 */
list_del(&rule->list);
nf_tables_rule_destroy(&chain_ctx, rule);
}
nf_tables_chain_destroy(&chain_ctx);이 코드는 chain->rules 연결 리스트를 실제로 순회하면서 리스트에 남아 있는
규칙 개수만큼만 use를 깎는다. 다시 말해 이 절차는 "만들어질 때부터 지금까지
chain->use에 반영된 증가분과, 지금 리스트에 실제로 걸린 규칙 수가 정확히
일치한다"는 것을 전제로 짜여 있다. 마지막에 호출하는 nf_tables_chain_destroy()는
바로 이 전제를 명시적으로 검증한다(nf_tables_api.c).
void nf_tables_chain_destroy(struct nft_ctx *ctx)
{
struct nft_chain *chain = ctx->chain;
...
if (WARN_ON(chain->use > 0))
return; /* 카운트가 안 맞으면 여기서 그대로 멈춘다 */
/* no concurrent access possible anymore */
nf_tables_chain_free_chain_rules(chain);
...
}주석(no concurrent access possible anymore)이 보여주듯, 이 함수는 "지금 이
시점에는 이 체인에 대한 다른 참조가 전혀 남아 있지 않다"를 신뢰하고 실제 메모리
해제 절차로 들어간다. 그 신뢰의 근거가 바로 chain->use == 0이라는 확인이다.
배치 하나에서 "방금 만든 체인"을 참조하려면 문제가 있다 — 그 체인은 아직 트랜잭션이
커밋되지 않았으므로 이름 기반 조회(nft_chain_lookup(), rhashtable 기반)에 아직
나타나지 않을 수 있다. 이를 위해 nf_tables는 같은 배치 안에서 대기 중인 트랜잭션
목록을 직접 스캔해 체인을 찾는 별도 경로를 둔다(nf_tables_api.c).
static struct nft_chain *nft_chain_lookup_byid(const struct net *net,
const struct nft_table *table,
const struct nlattr *nla)
{
struct nftables_pernet *nft_net = nft_pernet(net);
u32 id = ntohl(nla_get_be32(nla));
struct nft_trans *trans;
list_for_each_entry(trans, &nft_net->commit_list, list) {
struct nft_chain *chain = trans->ctx.chain;
if (trans->msg_type == NFT_MSG_NEWCHAIN &&
chain->table == table &&
id == nft_trans_chain_id(trans))
return chain;
}
return ERR_PTR(-ENOENT);
}즉 NFTA_RULE_CHAIN(이름)과 NFTA_RULE_CHAIN_ID(트랜잭션 로컬 ID)는 "같은 체인을
가리키는 두 가지 방법"이며, 둘 다 결국 같은 struct nft_chain *을 반환한다. 이
지점이 이 취약점의 핵심이다 — 바인딩 여부 검사가 이름 경로에만 있었을 뿐, 두
경로가 반환하는 객체 자체는 동일하다.
if (nla[NFTA_RULE_CHAIN]) {
chain = nft_chain_lookup(net, table, nla[NFTA_RULE_CHAIN],
genmask);
if (IS_ERR(chain)) {
NL_SET_BAD_ATTR(extack, nla[NFTA_RULE_CHAIN]);
return PTR_ERR(chain);
}
if (nft_chain_is_bound(chain))
return -EOPNOTSUPP; /* 이름 경로: 바인딩 체인 거부 */
} else if (nla[NFTA_RULE_CHAIN_ID]) {
chain = nft_chain_lookup_byid(net, table, nla[NFTA_RULE_CHAIN_ID]);
if (IS_ERR(chain)) {
NL_SET_BAD_ATTR(extack, nla[NFTA_RULE_CHAIN_ID]);
return PTR_ERR(chain);
}
/* ID 경로: nft_chain_is_bound() 검사가 아예 없음 */
} else {
return -EINVAL;
}NFTA_RULE_CHAIN으로 지정한 경우에는 nft_chain_is_bound(chain)이 참이면
-EOPNOTSUPP으로 즉시 거부한다. 그런데 바로 아래, NFTA_RULE_CHAIN_ID로 지정한
경우의 else if 분기에는 이 검사가 통째로 빠져 있다. 두 분기 모두 이후 코드에서
chain이라는 같은 지역 변수를 공유해 이어지는 규칙 삽입 로직(핸들 할당, 표현식
파싱, chain->rules에 연결 등)으로 흘러 들어간다 — 즉 검사만 빠졌을 뿐 나머지
파이프라인은 완전히 동일하다.
- 공격자는 하나의 넷링크 배치 트랜잭션 안에서,
NFT_CHAIN_BINDING플래그를 가진 익명 체인을 생성하는NEWCHAIN메시지와, 그 체인으로 점프하는jump/gotoverdict를 가진 규칙(NEWRULE)을 함께 보낸다 — 정상적인nft배치가 항상 하는 패턴이다. 이 규칙이 커밋되며nf_tables_bind_chain()이 호출되어chain->bound = true,chain->use = 1이 된다. - 같은 배치(또는 동일 커밋 이전 시점) 안에서, 공격자는 또 다른
NEWRULE메시지를 보내되 대상 체인을NFTA_RULE_CHAIN(이름)이 아니라NFTA_RULE_CHAIN_ID(1단계에서 만든 바인딩 체인의 트랜잭션 로컬 ID)로 지정한다.nft_chain_lookup_byid()는 검사 없이 정상적으로 그struct nft_chain *을 반환하고,nft_chain_is_bound()검사가 이 경로에는 없으므로 그대로 통과한다. 이 규칙은chain->rules리스트에 추가되고 일반적인 규칙 추가 경로를 통해chain->use도 함께 증가한다. - 이제 이 바인딩된 체인은 "소유자 immediate 표현식이 원래 의도한 것보다 많은
내용물(공격자가 몰래 끼워 넣은 규칙)"을 갖고 있다. 겉보기에는
chain->rules리스트와chain->use가 여전히 서로 일치하는 것처럼 보일 수 있다 — 문제는 리스트 항목 수 자체가 아니라, 바인딩된 체인은 오직 소유자만이 그 내용을 변경할 수 있다는 전제 위에서 짜인 다른 코드 경로들(예: 정리·삭제·verdict 재검증 로직)이 이 "몰래 추가된 규칙"의 존재를 고려하지 않는다는 데 있다. - 소유자 immediate 표현식이 삭제되면(예: 그 표현식을 담은 상위 규칙이 삭제되거나
트랜잭션이 롤백되면)
nft_immediate_destroy()가 호출되어chain->rules를 순회하며chain->use를 깎고 각 규칙을nf_tables_rule_destroy()로 해제한 뒤nf_tables_chain_destroy()를 호출한다. 업스트림 커밋 로그에 첨부된 실제 재현 결과는 이 시점에서 다음과 같은 경고를 보여준다.
WARNING: CPU: 2 PID: 13692 at net/netfilter/nf_tables_api.c:2013 nf_tables_chain_destroy+0x1f7/0x210 [nf_tables]
CPU: 2 PID: 13692 Comm: chain-bound-rul Not tainted 6.1.39 #1
RIP: 0010:nf_tables_chain_destroy+0x1f7/0x210 [nf_tables]
즉 nf_tables_chain_destroy()에 진입했을 때 chain->use > 0인 상태(개발자가
예상한 "정확히 0"이라는 불변식이 깨진 상태)로 도달했다는 뜻이다. 이는 바인딩된
체인의 소유권/생명주기가 오직 하나의 immediate 표현식이 전적으로 통제한다는
설계 전제가, NFTA_RULE_CHAIN_ID 우회로 끼어든 제3의 참조에 의해 깨졌음을
보여주는 직접적인 증거다.
[패치 전 시퀀스 — 바인딩 체인 소유권 침해]
NEWCHAIN(bound anon chain C)
│
NEWRULE(chain=<parent>, verdict=jump C)
│ nf_tables_bind_chain(C): C.bound=true, C.use=1
▼
C = { bound=true, use=1, rules=[] } ← "소유자는 오직 위 jump 규칙 하나"
공격자: NEWRULE(chain_id=<C의 트랜잭션 ID>, expr=...)
│ nft_chain_lookup_byid() → C 반환 (bound 여부 검사 없음!) ← 취약점 지점
▼
C = { bound=true, use=2, rules=[attacker_rule] }
(겉으로는 "정상적으로 규칙이 하나 더 들어간 체인"처럼 보이지만,
C는 여전히 "단일 소유자만 내용을 바꿀 수 있다"는 전제로 다뤄지는 바인딩 체인)
소유자 jump 규칙 삭제 → nft_immediate_destroy()
│ chain->rules 순회하며 use-- 반복, 각 rule 해제
│ 기대: 루프가 끝나면 use == 0
▼
WARN_ON(chain->use > 0) ← 예상과 어긋난 참조가 남아 불변식 위반
(커밋 로그의 재현 결과가 보여주는 지점 — 이후 어느 참조가 dangling
상태로 남는지의 정확한 세부 경로는 아래 "미해결/불확실 지점" 참고)
- 이 우회는
CAP_NET_ADMIN등 nf_tables 넷링크 소켓에 접근할 수 있는 권한이 필요하다 (CVSSPR:L과 일치) — 일반적으로 루트 네임스페이스에서는 관리자 권한이, 사용자 네임스페이스가 분리된 컨테이너 환경에서는 해당 네임스페이스 안에서의 낮은 권한만으로도 성립할 수 있다(unprivileged user namespace가 열려 있는 배포판 구성 한정). - 바인딩된 체인(
NFT_CHAIN_BINDING)을 만드는map/vmap등 verdict-map 계열 구문이 존재하는 nf_tables 서브시스템이 커널에 활성화돼 있어야 한다.
수정 커밋 0ebc1064e4874d5987722a2ddbc18f94aa53b211
(Pablo Neira Ayuso, 2023-07-23, "netfilter: nf_tables: disallow rule addition to
bound chain via NFTA_RULE_CHAIN_ID")의 실제 diff는 다음과 같다.
@@ -3811,8 +3811,6 @@ static int nf_tables_newrule(struct sk_buff *skb, const struct nfnl_info *info,
NL_SET_BAD_ATTR(extack, nla[NFTA_RULE_CHAIN]);
return PTR_ERR(chain);
}
- if (nft_chain_is_bound(chain))
- return -EOPNOTSUPP;
} else if (nla[NFTA_RULE_CHAIN_ID]) {
chain = nft_chain_lookup_byid(net, table, nla[NFTA_RULE_CHAIN_ID],
@@ -3825,6 +3823,9 @@ static int nf_tables_newrule(struct sk_buff *skb, const struct nfnl_info *info,
return -EINVAL;
}
+ if (nft_chain_is_bound(chain))
+ return -EOPNOTSUPP;
+
if (nla[NFTA_RULE_HANDLE]) {핵심은 이름 경로 안에 있던 nft_chain_is_bound() 검사를 그 자리에서 지우고,
두 분기(NFTA_RULE_CHAIN/NFTA_RULE_CHAIN_ID)가 합류하는 지점 바로 다음, 즉
chain 변수가 어느 경로로 얻어졌든 상관없이 공통으로 실행되는 위치로 옮긴 것이다.
패치 전에는 검사가 "체인을 어떻게 찾았는가"라는 우회 가능한 조건에 묶여 있었지만,
패치 후에는 "이 체인이 바인딩됐는가"라는 체인 자체의 불변 속성만으로 판단해, 조회
경로와 무관하게 항상 적용된다.
[패치 후 시퀀스]
NEWCHAIN(bound anon chain C) + NEWRULE(jump C)
│ C.bound=true, C.use=1
▼
공격자: NEWRULE(chain_id=<C의 트랜잭션 ID>, expr=...)
│ nft_chain_lookup_byid() → C 반환
│ chain = C (경로 무관하게 도달하는 합류 지점)
▼
if (nft_chain_is_bound(chain)) return -EOPNOTSUPP; ← 이제 여기서 항상 걸러짐
│
▼
-EOPNOTSUPP 반환, 규칙 추가 거부
(C.use는 1로 유지되고, 소유자 jump 규칙이 삭제될 때
nft_immediate_destroy()의 루프가 정확히 예상한 개수만큼만
use--를 수행 → WARN_ON(chain->use > 0) 도달하지 않음)
패치 전/후의 차이는 "검사를 어디에 놓느냐"라는 한 줄 수준의 변경이지만, 그 결과는 "바인딩된 체인의 내용물은 오직 소유자 immediate 표현식의 생명주기 하나로만 결정된다"는 설계 불변식이 조회 경로와 무관하게 실제로 지켜지느냐 여부를 가른다.
- CVE-2023-4147 — NVD
- 수정 커밋 0ebc1064e4874d5987722a2ddbc18f94aa53b211 — kernel.org cgit
NFT_CHAIN_BINDING도입 커밋 d0e2c7de92c7 — GitHub torvalds/linux 미러 (Fixes: 태그가 가리키는 원 커밋)- 패치 전 소스 nf_tables_api.c (v6.4 태그) — GitHub torvalds/linux 미러
- 패치 전 소스 nft_immediate.c (v6.4 태그) — GitHub torvalds/linux 미러
struct nft_chain정의 nf_tables.h (v6.4 태그) — GitHub torvalds/linux 미러- Red Hat Bugzilla 2225239 — CVE-2023-4147
공개 PoC 존재 여부는 검색으로 명확히 확인하지 못했다 (NVD/CISA SSVC 메타데이터는 "exploitation: poc"로 표시하고 있으나, 신뢰할 수 있는 구체적 공개 저장소 링크는 찾지 못했다). 이 리포트의 분석은 PoC가 아니라 위에 나열한 실제 커널 소스와 패치 diff, 업스트림 커밋에 첨부된 재현 경고 로그를 직접 대조해 작성했다.
- 업스트림 수정 커밋과 이 리포트가 직접 확인한 소스 대조는 "
chain->use가 예상보다 커서WARN_ON(chain->use > 0)에 걸린다"는 불변식 위반 자체까지다. 이WARN_ON가지는return으로 끝나 그 호출 시점 자체에서 이중 해제(double-free)나 즉각적인 메모리 손상이 일어나지는 않는다 — NVD가 "use-after-free"로 분류한 근거가 되는, 이 위반 이후 실제로 어떤 포인터가 해제된 메모리를 가리키게 되는지의 정확한 후속 경로(예: 다른 트랜잭션 커밋/롤백 경로가 이미 "파괴됨"으로 간주한 체인에 재접근하는 구체적 지점)는 원 보고자(Kevin Rich)의 상세 재현 자료나 별도 취약점 분석 없이는 확인하지 못했다. 공개된 상세 익스플로잇 문서를 검색으로 찾지 못해 이 부분은 추정으로 채우지 않고 확인 필요로 남긴다. - 바인딩된 체인에
NFTA_RULE_CHAIN_ID로 몰래 추가한 규칙이 구체적으로 어떤 표현식 조합(예: 또 다른jump로 같은 체인을 재귀 참조하는 경우 등)일 때 가장 직접적으로use불일치를 만들어내는지, 최소 재현 시나리오의 정확한 규칙 내용까지는 검증하지 않았다.