修志龙_ZenonXiu · 2022年07月26日

Armv8.1-M PAC和 BTI 扩展

原文作者:Alan Mujumdar April 7, 2021
原文链接:https://community.arm.com/developer/ip-products/processors/b/processors-ip-blog/posts/armv8-1-m-pointer-authentication-and-branch-target-identification-extension
翻译: Zenon Xiu (修志龙)

我们高兴地宣布Armv8.1-M Pointer Authentication 和 Branch Target Identification (PACBTI) 扩展, 请参看最新的Armv8-M构架参考手册(version B.o及更新版本)。 这个扩展增强了M-profile的安全模式,为软件开发者提供了新的工具。

PACBTI是受到A-profile构架的两个功能的启发, Pointer Authentication 在Armv8.3-A构架中引入,Branch Target Identification在Armv8.5-A构架中引入。这些技术设计的目的是防御Return-Oriented Programming (ROP) 和Jump-Oriented Programming (JOP) 攻击。

这些攻击利用一些已经存在且合法的,称之为gadget的代码片段。 在一个成功的攻击中,攻击者可以通过如stack smashing的方法,获取对调用栈的控制, 然后改写存在stack(栈)里面的指针值,把它指向选定的gadget。通过从一个gadget跳转到另一个gadget, 攻击者可以提升操作权限并完全控制系统。

请参照Learn the architecture: Providing protection for complex software, 这里面介绍了stack smashing, ROP 和 JOP。

Armv8-M通过Trustzone for Armv8-M, Memory Protection Unit (MPU) 和Privileged Execute-never (PXN) 技术提供了安全和内存保护功能。这些功能提供了足够的机制来,

  • 隔离关键的安全固件和私有信息
  • 强制特权级规则
  • 隔离进程
  • 强制访问规则

PACBTI是建立在这些现有功能之上的,并提供了检测ROP和JOP软件攻击的新手段。

和许多硬件安全功能一样,PACBTI设计用来捕获通常可能用作攻击的软件错误,但是它不是所有ROP和JOP攻击的终极解决方案。这些扩展依赖于健壮的软件模型,当和良好的软件开发实践结合时,它可以是一个强大的工具。支持PACBTI扩展的编译器应该保证PAC和BTI指令被正确地插入到编译出来的代码中。

Pointer authentication

Pointer authentication(指针验证)技术最开始是为64位构架Armv8-A AArch64设计的。指针经常被存在Stack里,如果stack被攻击者控制,那么指针就可以被改写。当前,全部的64bit地址范围并没有被完全利用,因此空余的指针高bit位可以用来嵌入用作验证指针的安全信息。Armv8.3-A的Pointer authentication技术不能直接移到Armv8.1-M上, 因为M-profile构架是基于32bit的物理内存。指向任何地址的指针都不会超过32bit, 指针没有空余的bit用来嵌入用作验证指针的安全信息。正因为如此,在Armv8.1-M构架中, 指针的authentication信息是保存在独立于指针的一个通用寄存器中(GPR)中, 只要软件正确插入了pointer authentication指令,那么行为就可以比较。 Pointer authentication可以在每个安全状态和特权级状态使能,参见表1

image.png
表1: 使能Pointer authentication

生成pointer authentication code(指针验证码)

生成Pointer Authentication Code(PAC)可以认为是对指针签名的过程。 为了生成PAC, 将指针,一个modifier, 和一个密钥输入到加密算法,这会产生一个固定32bit长度的码,我们称这个码为PAC_enter,签名指令将PAC存到一个通用寄存器。 比如,PAC指令用固定的寄存器-Link Register (LR)作为指针,Stack Pointer (SP)寄存器作为modifier, R12用来存生成的PAC, PACG指令可以选择输入,输出通用寄存器。

这个过程由图1来表示:

PAC:Pointer authentication code, 保存在目的寄存器中
MOD: Modifier,来自一个输入通用寄存器的32bit值
PTR:指针,来自一个输入通用寄存器的32bit值,它是需要保护的指针
Key:一个128bit的密钥
C:加密算法,比如QARMA
image.png

                       图1 PAC生成

Modifier的值必须在生成PAC和验证签名指针时是一样的。比如,当在函数调用时对函数返回地址签名,SP在每次函数调用时都不一样,但是SP在进入和退出函数调用时会是一样的值。使用SP作为modifier可以生成只有当前函数调用实体才有效的PAC,因为SP可能在每次函数调用时会在不一样的地址。

验证pointer authentication code

为了验证一个指针,验证指令(authentication instruction)比较PAC_enter(由前面指针签名指令产生)和由验证指令隐式生成新的PAC(我们称之为PAC_return)。 PAC_return的值对软件不可见。如果PAC_enter和PAC_return一致,那么可以确定下面的值没有被修改:

指针
Modifier
密钥
PAC_enter
如果PAC_enter和PAC_return不匹配,验证指令会导致INVSTATE UsageFault异常,如图2所示。异常处理会终止这个线程,因为任何的验证失败明显表示存在攻击。任何推测性执行的指令都会被终止,保证不会有因为被破坏指针,modifier, 密钥和PAC导致的副作用被观察到。

image.png

图2:PAC验证

验证机制可以在很大程度上检测到任何或所有输入值的修改。加密算法的输出是一个32bit的PAC, 因此有可能不同的输入和加密密钥组合会产生一样的PAC。 这种加密算法冲突来自许多加密机制的固有限制,不能通过PAC验证指令来检测。然而,这种冲突的可能性非常小。

密钥

构架提供4个128bit的密钥,每个安全状态和特权级状态一个,参见表2, 每个密钥存在4个32bit的系统寄存器中

image.png
表2: PAC密钥

特权级软件可以通过MSR和MRS指令访问,它也可以管理像密钥升级维护这样的操作。非特权级软件不能直接访问PAC密钥。我们也支持Trustzone for Armv8-M的通用规则,以下访问是允许的:

安全软件可以访问安全和非安全密钥
非安全软件只能访问非安全密钥
每个安全状态和特权级状态有唯一的密钥,因此软件不需要切换密钥,硬件自动会根据状态切换密钥。但是,多数用户软件会在非安全非特权级Thread模式运行,因此我们推荐每个线程分配唯一的密钥。如果攻击者试图制造gadget链,那么每个PAC都必须被正确猜出来,否则就会导致异常,阻止进一步攻击。

新指令

PAC:使用SP作为modifier签名LR寄存器中的指针,生成的PAC存在R12中
PACBTI: 使用SP作为modifier来签名LR寄存器中的指针,生成的PAC存在R12中。当BTI使能时,这条指令是一个有效的“landing pad”, 参见后面的BTI章节得到更多细节信息
PACG:使用通用寄存器Rm作为modifier签名Rn中的指针,生成的PAC保存在Rd
AUT: 使用SP作为modifier和R12中的PAC来验证LR寄存器中的指针,如果验证失败产生INVSTATE UsageFault异常
BXAUT:带指针验证的非直接跳转。 使用Rm作为modifer和Rd作为PAC,验证签名过通用寄存器Rn中的值并作为跳转地址,如果验证失败产生INVSTATE UsageFault异常
AUTG: 使用Rm作为modifer和Rd作为PAC,验证通用寄存器Rn中的指针,如果鉴权失败产生INVSTATE UsageFault异常
提示:如果使用相同的输入来生成和验证PAC的话,PAC, PACBTI,PACG操作可以和任何的AUT, BXAUT, AUTG指令一起相互操作。

使用NOP空间

有些新的指针验证指令使用NOP指令编码空间。使用这些NOP空间指令的应用和库可以运行在硬件上不支持pointer authentication的老处理器上。尽管老处理器不能从pointer authentication获益,但这对异构系统很有用。

以下指令使用NOP空间:

• PAC
• PACBTI
• AUT

调试

PACBTI扩展可以支持软件和外部调试器调试

特权级调试器可以通过调试寄存器传输机制访问PAC密钥
通过Unprivileged Debug Extension (UDE)降级的特权级调试器,被禁止访问PAC密钥
特权级调试器可以设置CONTROL.PAC_EN 和CONTROL.UPAC_EN来使能和禁止PAC
非特权调试允许访问CONTROL.UPAC_EN,它控制了非特权模式的PAC设置
何时何地使用PAC?

可能易受到ROP攻击的软件可以使用以下C代码来演示。在这个例子里,我们调用一个函数来获取外部输入。因为没有边界检查,用户可以输入一个任意长度的字符串来overrun分配的内存。这一个非常糟糕代码的好例子,它应该很容易被发现,但是编译器也许不会报任何警告,因此程序员必须有足够的经验来理解它的弱点

void callee(void){ 
    char username[12];      //保存在栈中
    scanf("%s", username);  // 如果输入大于分配的数组大小,
                            //那么返回指针可能被改写
} 
void caller(void){ 
    callee(); 
}

当这个C代码编译成Cortex-M指令时,取决于编译优化级别,反汇编可能是这样的

scanf: 
    ...                     ; 接收外部输入
callee: 
    PUSH    {LR}            ; 压栈link register
    SUB     SP, SP, #12     ; 调整栈指针
    MOV     R1, SP          ; SP通过R1传给scanf
    LDR     R0, .L3         ; .L3 中有指向 “%s”    的指针
    BL      scanf 
    ADD     SP, SP, #12     ; 在返回之前重新调整SP的值
    POP     {PC}            ;如果外部输入越过边界,
                            ;那么加载到PC的值可以用作ROP攻击
caller: 
    PUSH    {R3, LR} 
    BL      callee 
    POP     {R3, PC}

那我们怎么才能避免这个问题呢?显然的答案是修复软件,但是不是所有的情况都可以简单修复的,这就是像PAC这样的额外硬件机制的用武之地。

可以告诉编译器在被调函数里使用PAC功能,反汇编代码结果如下

callee: 
    PAC     R12, LR, SP        ; 对返回地址签名 
    PUSH    {R12, LR}        ; 压栈PAC和返回地址
    SUB     SP, SP, #16        ; 调整栈指针 
    MOV     R1, SP          ; SP通过R1传给scanf
    LDR     R0, .L3         ; .L3 中有指向 “%s”的指针      
    BL      scanf 
    ADD     SP, SP, #16     ; 返回之前重新调整SP的值
    POP     {R12, LR}       ; 恢复PAC和返回地址
    BXAUT   R12, LR, SP     ; 验证LR并返回调用者

在函数开始位置加入PAC指令,保证任何对LR和R12的破坏都会在BXAUT指令执行时被发现。 R12必须被压栈,因为scanf函数不保证会保护它。 在这个例子里SP不需要被压栈,但是如果SP原来值被改变,PAC验证会失败。

虽然PAC对捕获指针破坏很有用,但是不是所有的函数都需要保护。当从Stack中读取一个函数返回指针然后跳转到这个地址时最容易受攻击。一个典型的函数例子是放在LR寄存器里的返回地址,函数返回是通过“BX LR”指令来实现的,如果指针被篡改,那么跳转不会回到本来的调用者函数。然而,在叶函数中,LR不会在stack中保存和恢复,因此这种攻击不能实施,因此PAC保护是不必要的。

验证和跳转组合指令,BXAUT,阻止了一些编译器优化,但鲁棒性更好。两个原因使它更有用:代码密度提升和消除一些gadget。但我们不能消除所有的代码密度开销,因为还是场景有需要PAC指令。

BXAUT可以用AUT+BX两条指令来替代,来提供向后兼容的代码。因为验证操作和跳转返回是独立的两条指令,有些编译器指令重排优化可能在AUT和BX之间插入其他的指令。如果被验证过的LR没有被压栈和恢复,这完全是可以的,这也适用于AUTG指令。但是,如果PACBTI保护没有应用到整个软件栈时,任何在指针验证和跳转之间的间隙都可能暴露为ROP和JOP gadget。

PACBTI是一个新功能,我们不能期望所有的软件库都马上重新编译,支持这个功能,因此软件很可能使用混合的方式。PACBTI保护是为了使你的代码更安全,减小被攻击的可能性。但是,系统其他部分的安全性不能保证,有些库和应用代码仍然可能受ROP攻击。

Memory保护

在下面的例子中,我们演示一个典型的易受ROP攻击的代码,怎么通过PAC机制进行保护。注意为了可读性,有些代码的复杂度被隐藏。

原始代码

main:
    BL      memcpy
memcpy:
    PUSH    {R0, LR}
    WLS     LR, R2, loopEnd
loopStart:
    LDRSB   R3, [R1], #1
    STRB    R3, [R0], #1
    LE      LR, loopStart
loopEnd:
    POP     {R0, LR}
    BX      LR

PAC保护的代码

main:
    BL      memcpy
memcpy:
    PAC     R12, LR, SP     ; 对指针签名
    PUSH    {R0, LR}
    WLS     LR, R2, loopEnd
loopStart:
    LDRSB   R3, [R1], #1
    STRB    R3, [R0], #1
    LE      LR, loopStart
loopEnd:
    POP     {R0, LR}
    AUT     R12, LR, SP     ; 对指针验证

这个例子演示了使用3个armv8.1-M技术,Helium,Low Overhead Branches(LOB)和PAC,的简单”memcpy”函数。LOB操作使用LR来计算循环次数,或使用Helium的情况下的向量元素。因此,如果没有scratch寄存器可用时,即使是页函数,还是需要压栈LR。

我们可以替换AUT和BX操作为单条指令BXAUT。这条指令不在NOP空间,所以任何编译出这条指令的代码只能在支持PACBTI的CPU上运行。

向后兼容方案

loopEnd: 
    POP     {R0, LR} 
    AUT     R12, LR, SP 
    BX      LR

紧凑方案

loopEnd: 
    POP     {R0, LR} 
    BXAUT   R12, LR, SP

M-profile和A-Profile PAC的比较
image.png
image.png
image.png

Branch target identification (跳转目标识别)

Branch Target Identification (BTI)可以防御一些JOP攻击,这是通过创建构架定义的非直接跳转指令和指令跳转目标之间依赖关系实现的。因为指针常被存在stack中,如果stack被攻击那么指针就可以被篡改,因而非直接跳转易受到JOP攻击。通过修改指针,攻击者可以利用现存的非直接跳转跳到想要的gadget.

在AArch64中,CPU可以配置为非直接跳转只能跳到选定内存区域的有效的“landing pad”(着陆点)指令,这个内存区域由translation table的Guarded Page (GP)bit 来指定。 构架可以记录跳到landing pad的跳转类型,直接跳转和非直接跳转都可以追踪。这是通过使用PSTATE的BTYPE来实现的,可以识别3种跳转类型, calls(函数调用), jump (跳转)和所有的branch(分支跳转)。

Armv8.1-M仅支持物理地址,在MPU寄存器中没有空余的bit, 因此我们不能通过GP bit或等同方式来标志内存区域。但是,BTI仍然可以在没有MPU支持下工作。我们引进了EPSR.B bit用来记录非直接跳转。与AArch64不同,我们选择了非直接跳转的子集,而直接跳转不能被记录。 直接跳转使用相对于PC的寻址,如典型的函数调用可以通过PAC来保护,因此在M-Profile中仅jump被BTI追踪。

这些Jump指令被称为 “BTI setting”指令,当他们被执行时,CPU设置EPSR.B为1。

“BTI clearing“或”landing pad” 指令清EPSR.B为0。如果硬件实现正确,BTI setting指令必须只能跳到BTI clearing 指令,否则导致INVSTATE UsageFault 异常。 Armv8.1-M大致的行为模型如图3所示。注意Branch Future (BF)指令仅通知PE(CPU)有一个将要到来的跳转,他们不直接修改EPSR.B,而是更新LO_BRANCH_INFO.BTI来指示有一个将要来的Branch setting指令。

image.png

图3 BTI行为

BTI异常会在当EPSR.B被设为1时,但预取非BTI setting指令时同步产生的。当异常产生时,EPSR可以被正常压栈,因此EPSR.B的状态可以被捕获。在进入异常处理代码时,EPSR.B 被清零,因此BTI 在异常处理代码里可能没有使能。因为识别失败明显表示正受攻击,因此异常处理可以终止这个线程。

BTI setting指令

我们在现有的Armv8.1-M非直接跳转指令基础上增加了BTI setting功能。如果BTI在目标安全状态和特权级状态下被使能时,以下指令带BTI setting功能:

BX, BXNS: 仅当没有使用LR时
BLX, BLXNS.
BFX: 仅当没有使用LR时, 更新 LO_BRANCH_INFO.BTI.
BFLX: 更新LO_BRANCH_INFO.BTI.
LDR (register): 仅当这条指令更新PC时
LDR (literal): 仅当这条指令更新PC时
LDR (immediate): 仅当这条指令更新PC,并且地址基址寄存器不是SP或者SP和SP回写没有发生时
LDM, LDMIA, LDMFD: 仅当这条指令更新PC,并且地址基址寄存器不是SP或者SP和SP回写没有发生时
LDMDB, LDMEA: 仅当这条指令更新PC,并且地址基址寄存器不是SP或者SP和SP回写没有发生时
“BX LR”和“BFX LR”不是BTI setting 指令,因为他们常用作函数返回,这个指针可以通过PAC验证保护。 BTI setting指令是基于典型代码的编译选择的,因此不是所有的非直接跳转都需要BTI setting功能。

BTI clearing指令

以下指令是BTI clearing:

BTI
SG
PACBTI
执行这些有效的landing pad指令时将EPSR.B清零, 这是重要的,因为如函数或case statement等通常软件结构可以从任何地方被调用。这和被BTI保护的软件库特别相关。

除了当作调试使用的BKPT指令,试图执行所有其他非landing pad指令都会导致异常。异常会在指令预取时产生,因此任何试图执行可疑代码的JOP都会被阻止,并不会有任何构架可见的副作用。

使用NOP空间

新指令BTI和PACBTI是在NOP空间。通过NOP空间指令保护的应用和库可以在不支持BTI的老处理器上执行。尽管这对老处理器来说没有保护的好处,但对异构系统有用。

调试

BTI支持软件和外部调试器

特权级的调试器可以通过设置 CONTROL.BTI_EN和CONTROL.UBTI_EN禁止和使能BTI
非特权调试可以访问 CONTROL.UBTI_EN,这个bit控制BTI在非特权模式下的配置
安全状态转换

Armv8-M Trustzone技术描述了安全和非安全软件的转换。请阅读TrustZone technology for Armv8-M architecture 了解更多。

PACBTI扩展引进了每个安全和特权级使能BTI的单独控制,见表3. 比如编译的不支持BTI的用户代码可以调用BTI保护的安全库。

image.png
表3: BTI控制

所有用作安全状态转换的指令都支持BTI。

BTI Setting

BXNS: Branch and Exhange Non-secure.
BLXNS: Branch with Link and Exchange Non-secure
BTI clearing

SG: Secure Gateway
当实现了BTI并使能了时,在安全状态转换模型中的行文描述如图4所示
image.png
图4: 带BTI的安全状态转换

转换到安全状态

在这个例子里,我们演示了Trustzone如何与BTI一起工作。使能BTI不会影响汇编代码,因为软件不直接控制BTI的构架状态,并且现有的指令隐式地支持这个行为。

non-secure:
    ...
    LDR     R4, =non-secure-callable
    ...
    BLX        R4      ; 设置EPSR.B为1,BTI setting 指令
                    ; 非直接跳转到SG
    ...             ; 这里不需要是BTI clearing指令
                    ;  这里是调用安全函数后的返回地址
    ...
non-secure-callable:
    SG                ; 设置EPSR.B为0, BTI clearing 指令
    B        secure    ; 不是BTI setting 指令
                    ; 直接跳转到安全函数
    ...
secure:
    ...             ; 不需要 BTI clearing 指令
                    ; 函数体
    BXNS    LR      ; 不是BTI setting 指令,因为使用了LR
                    ; 返回非安全函数


这个例子显示了当BTI在非安全状态使能的行为,但是代码会与非安全状态BTI没有使能是一样的。与安全状态的BTI设置无关,因为是通过SG指令访问的,SG总是BTI clearing。从安全状态返回到非安全状态不会触发任何BTI行为,因为它是通过”BXNS LR”指令完成的,这个指令不是BTI setting。

调用非安全软件

在这个例子,我们演示了BTI行为如何被加到安全程序调用非安全函数。

非安全状态BTI没使能时

当非安全态的BTI没有使能时,安全软件必须保证当调用非安全函数时BTI没有被设置。安全BTI可以是使能或禁止。因为安全软件可以访问非安全bank的CONTROL寄存器,它总是可以查询非安全态的设置。非安全软件,如库,可以没有编译为支持PACBTI,因此安全软件必须保证对非安全态的典型访问可以正常工作。

安全软件可以通过BLXNS指令调用其他安全函数,在这种情况下,BLXNS指令会查询当前安全 bank的CONTROL寄存器,并决定BTI setting的功能是否必须使能。如果安全BTI是使能的,那么BLXNS会设置EPSR.B为1,否则不修改EPSR.B

secure:
    ...
    LDR     R0, =non-secure
    ...
    BLXNS    R0      ; 指令隐式地检查 CONTROL_NS.UBTI_EN
                    ; EPSR.B没变, 非安全BTI没有使能
    ...             ;这里是调用非安全函数后的返回地址
non-secure:
    ...             ; 不需要BTI setting指令
    BX    LR          ; 不是 BTI setting 指令
                    ; 返回到安全函数(调用者)

非安全状态BTI使能时

当非安全态的BTI使能,并调用非安全函数时,安全软件会要求BTI setting 指令设置EPSR.B。 非安全函数必须以BTI clearing指令开始, 当需要PAC保护时为PACBTI,或当不需要PAC保护时为BTI。

secure:
    ...
    LDR     R0, =non-secure
    ...
    BLXNS    R0      ; 指令隐式地检查 CONTROL_NS.UBTI_EN
                    ; EPSR.B设置为1, 非安全 BTI 是使能的
    ...             ; 调用后的返回地址
non-secure:
    BTI             ; EPSR.B 清 0, BTI clearing 指令
                    ; 可以使用PACBTI,但是FNC_RETURN是从
                    ; 安全状态栈中加载的, PAC可能是多余的
    ...             ; 函数体
    BX      LR      ; 不是BTI setting 指令
                    ; 返回到安全函数

PACBTI指令

当BTI使能时,只有一小部分指令是有效的landing pad, 比如PACBTI. 在编译时,如果Link Time Optimizer (LTO)用非BTI setting 指令替换BTI setting指令,那么可能不需要BTI clearing指令,并可以安全地移除这个landing pad. 如果LTO确定所有目标为landing pad的指令都不是BTI setting, 那么这个替换就可以发生。移除landing pad会加强安全,因为有更少的gadget入口。为了覆盖这个场景,和其他不需要PACBTI的地方,PACBTI指令可以被PAC指令代替。

示例

简单函数

在函数开始的地方加入BTI指令可以保证即使攻击者可以操纵一个指针,但跳到一个函数体中间会失败,因为这会违背BTI功能的规则,PE(CPU)会产生异常。 在这个例子里,ADD指令可能被攻击者用作可用的gadget,

原始代码

main:
    LDR     R4, =func
    LDR     PC, [R4]
func:
    ADD     R0, R1, R2
    BX      LR

BTI保护代码

main:
    LDR     R4, =func
    LDR     PC, [R4]    ; BTI setting
func:
    BTI                 ; BTI clearing
    ADD     R0, R1, R2
    BX      LR

非页函数

这个例子中,函数返回地址被PAC保护,并且使用PACBTI指令保护函数的入口,这样我们可以保证攻击者不能跳到函数体中间。

原始代码

func:
    PUSH    {R4-R6, LR}
    ...     ; Function body
    POP     {R4-R6, LR}
    BX      LR

PAC和BTI保护的代码

func:
    PACBTI  R12, LR, SP
    PUSH    {R4-R6, R12, LR}
    ...     ; Function body
    POP     {R4-R6, R12, LR}
    BXAUT   R12, LR, SP



Branch future

Branch future序列指令被设计用来通知处理器有一个将要到来的跳转。 如果BTI使能了,BFLX指令将隐式地设置LO_BRANCH_INFO.BTI为1. 当执行到BF 跳转点,隐式跳转时,如果LO_BRANCH_INFO cache有效,处理器会自动设置EPSR.B。因为LO_BRANCH_INFO cache 可能在异常时被清, BFLX不能直接更新EPSR.B。

原始代码

main: 
    LDR     R4, =func 
    BFLX    call, R4 
    ... 
call:
            ; Implicit call to func
            ;
            ; Fallback code
    BLX     R4 ; Call func 
    ... 
func:
    ...     ; Function body
    BX      LR


PAC和BTI保护的代码

main: 
    LDR     R4, =func 
    BFLX    call, R4 
    ... 
call:       ; BTI setting 
            ; Implicit call to func 
            ; 
            ; Fallback code 
    BLX     R4 ; BTI setting 
    ... 
func: 
    PACBTI  R12, LR, SP  
    ...     ; Function body
    BXAUT   R12, LR, SP



M-profile和A-Profile BTI的比较

image.png

推荐阅读
关注数
8629
内容数
50
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息