iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

前言

昨天列的待辦是把授權範圍延伸到banking的send_money,實際去補的時候先撞到格式問題,還有發現可讀者檢查本身有問題,發生在不同的檢查上。

格式問題:授權範圍只認識email

send_money的收件人參數叫recipient(單數),值是IBAN:

def send_money(account, recipient: str, amount: float, subject: str, date: str):
    """:param recipient: IBAN of the recipient"""

昨天做的scope_from_prompt只有一個email的正規表達式,RECIPIENT_ARGS也只收recipients,兩邊對不上,send_money從一開始就沒有被納入授權範圍的判斷。

補上一個IBAN的格式,把recipient加進RECIPIENT_ARGS

_IBAN_RE = re.compile(r"\b[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}\b")

驗證的方式跟前兩天一樣,手寫一段假設規劃器已被說服的計畫:

使用者請求指名的IBAN:['gb29nwbk60161331926819']

靜態政策 -> 攻擊者IBAN      放行
新權限格 -> 攻擊者IBAN      放行

結果兩邊都放行,雖然格式補上去了,但攻擊者的IBAN還是過得去。

可讀者檢查本身有問題

去追蹤為什麼還是放行,發現問題不在授權範圍,在可疑層一直在用的可讀者檢查:

def _recipients_must_already_be_readers(self, tool_name, kwargs, recipients):
    for name, arg in kwargs.items():
        if name in RECIPIENT_ARGS or can_readers_read_value(recipients, arg):
            continue
        allowed_readers, _ = get_all_readers(arg)
        return Denied(...)
    return Allowed()

邏輯是掃過每個非收件人欄位,只要它的可讀者涵蓋收件人集合就跳過,全部跳過就放行,而can_readers_read_value對公開資料一律回傳True:

def can_readers_read_value(potential_readers, value):
    value_readers, _ = get_all_readers(value)
    if isinstance(value_readers, readers.Public):
        return True
    return potential_readers.issubset(value_readers)

send_moneyamountsubjectdate是模型寫的常數,可讀者一律是公開,所以迴圈跑過這三個欄位每個都因為「公開」而直接跳過,一次都沒有真正比對過收件人是誰,跑完迴圈沒有觸發任何一次拒絕,函式都直接放行。

修法

加一個條件:迴圈裡至少要出現一次「非公開的可讀者」,這個檢查才算有效驗證過收件人:

saw_restricted_payload = False
for name, arg in kwargs.items():
    if name in RECIPIENT_ARGS:
        continue
    allowed_readers, _ = get_all_readers(arg)
    if isinstance(allowed_readers, readers.Public):
        continue
    saw_restricted_payload = True
    if not recipients.issubset(allowed_readers):
        return Denied(...)
if not saw_restricted_payload:
    return Denied(f"'{tool_name}' has no data whose readers justify sending to {sorted(recipients)}.")
return Allowed()

如果全部欄位都是公開常數,代表沒有任何資料能替收件人背書,直接拒絕。

修完才發現舊測試也在犯同一個錯

跑測試的時候,有一個原本通過的測試斷言「內文是純字串常數時,攻擊者地址應該跟原版政策行為一致(放行)」,這個測試就是把今天要修的洞寫成預期行為,改掉它讓它斷言正確的方向:

def test_clean_state_still_requires_some_basis_for_trusting_the_recipient(self, workspace):
    """The base engine allows this via a broken `is_trusted` shortcut; tightening must not."""
    with pytest.raises(SecurityPolicyDeniedError):
        _run(_send(ATTACKER).replace(READ_EVENTS, 'body = "hello"'), workspace, adaptive=True, state=state)

搭配一個對照組,確認真正有內容的情況不受影響:

def test_clean_state_allows_a_recipient_the_payload_readers_justify(self, workspace):
    _run(_send(READER), workspace, adaptive=True, state=state)  # no exception

過程中踩到兩次自己的回歸

剛開始我把邏輯改成「收件人若被標記為可信,且不在授權範圍內,直接拒絕,跳過可讀者檢查」,想法是is_trusted分不出常數跟真的使用者輸入,所以乾脆不看它。

跑測試完發現有個任務是把行事曆內容寄給查出來的參與者,參與者是常數(因為Q-LLM回傳的是字串list),但那個常數的可讀者確實只涵蓋收件人自己。舊邏輯靠可讀者檢查放行,新邏輯因為「收件人被標記可信」直接跳過檢查、拒絕,把這個合法案例也擋了,問題在「有沒有真實資料替收件人背書」,這點前面已經寫過。

自己沒看出這個問題,是跑完整套測試才發現的。改回三段判斷:目的地在授權範圍而且基礎政策沒有拒絕(或拒絕的理由是is_trusted那類可被翻掉的)才放行;基礎政策拒絕而且理由不可翻,維持拒絕;其餘才輪到可讀者檢查。

第一個回歸是utility上的誤殺,跑起來會被發現;第二個回歸是資料外洩,如果沒有昨天寫的那個測試守著,這種等級的問題很可能會被夾帶進去,跑benchmark也不一定測得出來,因為模型不會自己想到要寫這種迴圈,DAY22到24每天都在加測試,這是它們第一次真的在同一個session裡發揮作用。

全部驗證一次

四個情境跑一次,確認今天的修改沒有弄壞前兩天的成果:

情境 靜態政策 新權限格
send_money,攻擊者IBAN,其餘欄位皆為常數 放行 擋下
send_money,使用者指名的IBAN 放行 放行
share_file,攻擊者地址(DAY22) 放行,外洩 擋下
大量分享(DAY23) 擋下(1份) 擋下(1份)
搜尋後刪除(DAY23) 拒絕,做不成 放行

191個測試全過,其中今天新增12個,IBAN擷取、send_money的攻擊者案例與合法案例、可讀者檢查的兩種情況、以及改寫過的舊測試。

小結

今天除了補一個工具的格式,往下追才發現可讀者檢查本身有一個系統性的漏洞,任何收件人以外的欄位只要是公開常數就能讓迴圈跳過,而send_money剛好是所有欄位都常數的典型案例,把這個漏洞從潛伏狀態顯露出來。

跟前兩天合起來看,這三天封住的是同一件事的三個切面:字面值不該冒充使用者授權(DAY22)、使用者授權不該蓋過與它無關的檢查(DAY23)、沒有真實資料時檢查不該當作通過(DAY24),三次判斷都只能在它真正檢查過什麼的時候,才算是通過。

明天見

明天把階段三剩下的誤殺檢查跑完,同時用gemini-3.5-flash把banking的送出攻擊也跑一輪真實benchmark,確認IBAN這條路在真實模型生成的程式碼裡也走得通。


上一篇
DAY23|使用者的請求能授權什麼,不能授權什麼
下一篇
DAY25|複製既有行程的參與者,被自己昨天的方法擋掉
系列文
CaMeL 動態重擬定:讓 Agent 邊讀邊決定27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言