返回位址
當你暫停手邊的工作去問同事一個問題時,你會用手指按住正在讀的那一行,好讓自己能精準地從那裡繼續。返回位址(return address)就是函式呼叫的那根手指。它是呼叫之後緊接著那道指令的記憶體位址:被呼叫的函式一做完,處理器就必須跳回那個位置,讓呼叫者從暫停處精準地接續下去。
當一道呼叫指令執行時,硬體會自動算出返回位址(其實就是呼叫指令的位址再加上一道指令的大小),並把它藏到安全的地方。在 RISC-V 上那個地方是名為 ra 的暫存器,由 jal 指令設定;返回就是跳回 ra 所存的那個位址。問題在於 ra 只有一個。若被呼叫的函式回頭又呼叫別的東西,第二個呼叫會用新的返回位址覆蓋 ra。因此一個會發出巢狀呼叫的函式,必須先把自己的 ra 存到堆疊上,做完事,再在返回前還原 ra。葉子函式(不呼叫任何人的函式)通常可以省去這個保存動作。
返回位址對正確性、效能與資安都很重要。弄錯了,程式就會返回到錯誤的地方然後當掉。它還有陰暗的一面:因為返回位址就放在堆疊上的記憶體裡,一個讓攻擊者得以覆蓋它的漏洞,就能把執行流導向攻擊者選定的程式碼。這正是經典「堆疊破壞」攻擊的核心,也是現代系統加上堆疊金絲雀(stack canary)與影子堆疊(shadow stack)等防禦、來保護這個寶貴值的原因。
一個會呼叫別人的函式必須保住 ra: outer: addi sp, sp, -8 sd ra, 0(sp) # 在呼叫任何人之前,先存好我自己的返回位址 jal ra, inner # 這會用新的返回位址覆蓋 ra ld ra, 0(sp) # 還原我自己的返回位址 addi sp, sp, 8 ret # 現在 ret 才能正確地回到 outer 的呼叫者
因為 ra 只有一個,非葉子函式必須在內層呼叫前後保存並還原它。
覆蓋已保存的返回位址正是緩衝區溢位攻擊的手法;返回位址是攸關資安的值,而不只是個記帳細節。