iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
AI Security

合法呼叫湊出的攻擊鏈:AI Agent 防禦的 30 天觀念養成系列 第 12

Day 12|argument 被改寫之後,runtime 還追得到它的來源嗎

  • 分享至 

  • xImage
  •  

前言

Day 11 的工具呼叫不再直接由模型觸發,中間插了一層 CapabilityManager,runtime 先確認 agent 有沒有使用某個工具的能力,例如 agent → fetch_url。有這個能力就放行,沒有就擋。做到這裡,我原本以為執行期的授權邊界已經差不多定型了。

但接著往下測才發現,這個授權其實只看到了「誰要用什麼工具」,還沒看到「工具拿到的資料是怎麼來的」。問題是這個 URL 可能是上一個步驟從外部內容讀出來的,而且中間還經過了抽取、改寫,甚至由模型重新產生。

external data → extract → rewrite → model output → tool argument

如果來源資訊在這條路上掉掉,runtime 到最後只剩下一個字串,就算前面的授權還存在,也沒有足夠資訊判斷這個 argument。Day 12 要處理的就是這一層:一個原本有來源的值,經過 extract、rewrite、recombine,甚至模型重新產生之後,runtime 到底還能不能知道它來自哪裡,以及這個來源的信任程度要怎麼繼承。


先讓 runtime 記住來源

我先沒有碰模型,而是從資料結構開始改。既然之後要根據來源做判斷,runtime 就不能只保存最後的字串,還要一起保存它是怎麼形成的。

這裡的 provenance(來源追蹤),簡單講就是在資料往後流動時,記住「這個值是從哪裡來的、經過哪些資料形成」。它不是單純記錄最後的內容,而是保留內容和原始來源之間的關係。

from dataclasses import dataclass

@dataclass
class PVal:
    value: str
    input_trusts: set
    verbatim_trace: str | None

PVal 是這次測試用來包裝資料的結構;value 是目前實際使用的值,input_trusts 記錄產生這個值時用過哪些來源,verbatim_trace 則比較嚴格,只有在目前的值還能直接對應到原始資料某一段時才保留。這裡的 verbatim_trace 可以理解成「逐字來源鏈」,用來判斷目前的值是否還能直接追溯到原始內容。

例如外部資料裡原本有:

https://internal.example

runtime 如果只是把這段 URL 原封不動抽出來,那還能知道它就是原始資料中的那一段。但如果後面做的是 https://internal.example → https://internal.example/api,或是模型根據這段資料重新產生一個新的 URL,就不能再假設最後的值和原始資料存在逐字對應關係。

來源在第一次進入 runtime 時,我先給它一個信任標籤:

TRUSTED    |    UNTRUSTED    |    UNKNOWN

這裡的 trust label 可以把它理解成 runtime 對資料來源的安全分類:TRUSTED 代表可信來源、UNTRUSTED 代表不可信來源,而 UNKNOWN 則代表目前沒有足夠資訊判斷。這個標籤由 runtime 建立,而不是由模型自己決定。模型可以說「我已經清理過這個 URL」,但這句話不能當成它變成 TRUSTED 的證據,否則信任邊界最後會變成模型自己宣告。


改寫之後,來源要怎麼繼承?

有了基本資料結構後,下一個問題就是來源繼承規則。我這次先測兩種方法。

第一種是只相信可以逐字追蹤的來源,我把它記成 S1(逐字來源追蹤策略)。它的核心概念是:只有還能直接證明「這個值就是原始資料中的那一段」時,才繼承原本的信任狀態。

def label_S1(p):
    if p.verbatim_trace == UNTRUSTED:
        return UNTRUSTED
    if p.verbatim_trace == TRUSTED:
        return TRUSTED
    return UNKNOWN

這個方法的判斷很單純。原文抽取時可以繼承來源,但只要經過改寫,讓最後的值無法再和原始資料逐字對應,就變成 UNKNOWN

逐字抽取 → 保留原本來源    |    改寫/重新產生 → UNKNOWN

第二種方法不要求最後的值一定要逐字對回原始資料,而是看這個值形成過程中使用過哪些來源。我把它記成 S2(輸入來源傳播策略)。這比較接近資料流分析裡的保守式標籤傳播:只要形成目前值的輸入中曾經出現不可信來源,就把這個狀態一路帶下去。

def label_S2(p):
    if UNTRUSTED in p.input_trusts:
        return UNTRUSTED
    if p.input_trusts == {TRUSTED}:
        return TRUSTED
    return UNKNOWN

也就是只要輸入中出現 UNTRUSTED,結果就繼承不可信;如果所有輸入都是 TRUSTED,才繼承可信;其他情況則保持 UNKNOWN

這兩個方法其實是在做不同的取捨。S1 重視的是「現在這個值還能不能直接證明自己來自哪裡」,S2 則重視「形成這個值的過程中曾經使用過哪些來源」。從學術角度看,這可以視為兩種不同的 provenance propagation(來源傳播)策略,沒有哪一個規則可以脫離實際資料流直接說一定正確。


先固定最後的授權規則

為了比較兩種方法,我先把 sink 的判斷固定下來。這裡的 sink(資料流終點),可以簡單理解成「資料最後要被拿去做某個動作的位置」,例如這次的 fetch_url 或 internal sink。

def sink_policy(label):
    return "DENY" if label == UNTRUSTED else "ALLOW"

也就是:

TRUSTED → ALLOW    |    UNTRUSTED → DENY    |    UNKNOWN → ALLOW

這裡特別要說明,UNKNOWN → ALLOW 只是這次實驗的設定,不是我認為實務上一定應該這樣做。如果改成 UNKNOWN → DENY,後面的結果當然會跟著變。我先把它固定,是為了單獨觀察不同來源繼承方式會造成什麼結果。


prov_harness.py:先不要讓 LLM 進來

這次的測試我沒有直接接真正的 LLM。原因是如果模型本身也加入隨機性,最後發生問題時很難判斷究竟是模型產生了不同結果,還是 provenance 的處理方式出了問題。所以我另外做了一個 prov_harness.py,把測試流程固定下來。

測試裡的輸入、處理步驟都固定,fetch_url 和最後的 internal sink 都使用 mock;來源的 trust label 由 runtime 建立,extract_url 模擬原文抽取,而 rewrite_url 則模擬重新組出一個值。這樣測到的是「資料經過不同處理後,來源標籤怎麼被繼承」,而不是在測 LLM 本身的生成能力。

E1~E3 也是同樣的做法。它們是人工構造的模型生成情境,不是讓模型真的跑很多次後統計結果。每個案例先設定預期來源,再和 runtime 算出的結果比較。這裡的 ground truth(真實標準答案) 指的是測試案例事先定義的正確狀態,用來判斷 runtime 的決策到底有沒有錯;如果應該擋卻放行,就是 FN(False Negative,漏判),如果應該放行卻擋下,就是 FP(False Positive,誤擋)


第一組:直接使用、抽取與改寫

為了避免後面的案例代號看起來突然,我先把這次測試的編號規則定下來。

A 組代表直接使用;B 組代表不可信來源經過處理;C 組代表可信來源經過處理。
其中編號 1 代表原文抽取,2 代表非原文改寫。

因此,B1 就是「不可信來源+原文抽取」,B2 則是「不可信來源+改寫」;C1、C2 的差別也相同,只是原始來源換成可信。

案例 原始來源 處理方式 S1:逐字追蹤 S2:輸入來源追蹤
A:直接使用 不可信 不可信 → 擋下,正確 不可信 → 擋下,正確
B1:不可信+抽取 不可信 原文抽取 不可信 → 擋下,正確 不可信 → 擋下,正確
B2:不可信+改寫 不可信 非原文改寫 不知道 → 放行,FN(漏判) 不可信 → 擋下,正確
C1:可信+抽取 可信 原文抽取 可信 → 放行,正確 可信 → 放行,正確
C2:可信+改寫 可信 非原文改寫 不知道 → 放行,正確 可信 → 放行,正確

A 和 B1 都沒有問題,因為來源沒有消失。B1 雖然經過了 extract,但它只是把原本的資料抽出來,所以 verbatim_trace 還能保留。

真正出問題的是 B2。原始資料是不可信的,但經過改寫後,最後的值已經不是原始資料中的那段字串。S1 因為找不到逐字對應,只能把結果標成 UNKNOWN。這次實驗又設定 UNKNOWN → ALLOW,於是最後就形成:

不可信來源 → 改寫 → UNKNOWN → ALLOW → FN(漏判)

S2 則不要求最後的值一定能逐字對回原始資料,只要知道它形成時使用過不可信來源,就會保留 UNTRUSTED,因此 B2 會被擋下。

這裡其實已經出現一個很重要的現象:最後的 argument 可以完全一樣,但來源仍然可能完全不同。

TRUSTED   → rewrite → https://internal.example
UNTRUSTED → rewrite → https://internal.example

如果 runtime 只看最後的 URL,兩次呼叫沒有任何差別。但如果把 provenance 一起帶到授權層,這兩個 argument 就可以被分開處理。這也是 Day 11 和 Day 12 的主要差異:Day 11 解決「agent 有沒有 fetch_url 這個能力」,Day 12 開始處理「它拿什麼去呼叫 fetch_url」。


第二組:混合來源

第一組看的是單一來源。接下來我把不同信任程度的資料混在同一次處理裡。

這一組記成 D 組(mixed source,混合來源),也就是「一個結果同時使用了不同信任程度的資料」。其中 D1 是最後真正使用不可信資料的情況,D2 則是最後真正使用可信資料,但過程中同時碰過不可信資料。

案例 最後真正使用的來源 處理時使用的資料 S1:逐字追蹤 S2:輸入來源追蹤
D1:最後使用不可信資料 不可信 含有不可信資料 不知道 → 放行,FN(漏判) 不可信 → 擋下,正確
D2:最後使用可信資料 可信 同時含有不可信資料 不知道 → 放行,正確 不可信 → 擋下,FP(誤擋)

D1 和 B2 類似。最後真正使用的是不可信資料,但因為經過處理後無法逐字追蹤,S1 只得到 UNKNOWN,在目前的規則下就會放行。

D2 就不一樣了。最後真正形成結果的是可信資料,但處理過程中同時碰過不可信資料。S2 的規則只看輸入來源,所以直接把結果標成 UNTRUSTED,最後擋掉。

如果以這次測試的 ground truth 計算,結果會變成:

S1 → 2 次 FN(漏判) / 0 次 FP(誤擋)
S2 → 0 次 FN(漏判) / 1 次 FP(誤擋)

這裡不能直接下結論說 S2 比較安全。它只是把錯誤推向了另一邊。S1 比較容易在來源被改寫後失去不可信來源;S2 則可能因為資料混合而過度阻擋。

所以 provenance 並不是「把來源一路往後複製」這麼簡單。真正要定義的是,不同 transformation 之後,哪些來源關係應該被保留。


第三組:模型重新產生資料

如果只是程式做 extractrewrite,至少還可以明確描述輸入和輸出的關係。模型重新產生一個新的值時,這個關係就更難判斷。

這一組記成 E 組(模型重新產生)。E1 是根據不可信資料重新產生,E2 是根據可信資料重新產生,E3 則是同時根據可信與不可信資料重新產生。

例如模型看到:

請查詢 https://internal.example

最後自己產生:

https://internal.example/api/status

這個新字串並不是原始資料中的任何一段,所以 verbatim_trace 沒有辦法保留,只能變成 None

這時我測了三個案例:

案例 資料怎麼產生 預期來源 S1:逐字追蹤 S2:輸入來源追蹤
E1:根據不可信資料產生 根據不可信資料重新產生 不可信 不知道 → 放行,FN(漏判) 不可信 → 擋下,正確
E2:根據可信資料產生 根據可信資料重新產生 可信 不知道 → 放行,正確 可信 → 放行,正確
E3:根據混合資料產生 根據混合資料重新產生 沒有客觀答案 不知道 → 放行 不可信 → 擋下

E1 很容易理解。原始資料是不可信的,但模型重新產生後已經找不到逐字來源,所以 S1 只能得到 UNKNOWN,最後因為目前的政策是 UNKNOWN → ALLOW 而放行。

E2 則剛好相反。S1 一樣不知道來源,但這次實際上是可信資料,因此放行並沒有造成錯誤。

這兩個案例放在一起看,UNKNOWN 的問題就很明顯了。它不是「不可信」,也不是「可信」,而是 runtime 手上的資訊不足,無法證明它應該繼承哪一個來源。

UNKNOWN ≠ 不可信
UNKNOWN ≠ 可信
UNKNOWN = runtime 缺乏足夠的來源資訊

E3 更能說明這件事。模型同時看了可信和不可信資料,最後重新產生一個新的值。如果沒有其他證據,我們甚至沒有客觀 ground truth 可以說這個結果到底應該算可信還是不可信。因此 E3 不應該硬算成 FP 或 FN。

runtime 真正需要處理的是「不知道」這個狀態本身。可能的政策至少有三種:

UNKNOWN → ALLOW    |    UNKNOWN → DENY    |    UNKNOWN → 進一步檢查

不同工具、不同 argument,甚至不同風險等級,都可能需要不同策略。但有一件事不能反過來:模型不能自己把 UNKNOWN 宣告成 TRUSTED


回頭看 Day 11,argument 其實是缺掉的一層

Day 10 我看的主要是:

source → transformation → sink

Day 11 加入 runtime 授權:

principal → capability

Day 12 則把兩條線接在一起:

source → transformation → argument → authorization

同一個 agent 都可以合法呼叫 fetch_url,但它傳進去的 URL 可能來自可信設定,也可能來自外部文件,甚至可能是模型根據不可信內容重新產生的結果。最後的字串看起來一樣,不代表它們應該得到一樣的授權結果。

所以更完整的授權判斷可以變成:

(principal, capability, argument provenance) → decision

這裡的 principal 是「誰在要求執行」,capability 是「它被授予什麼能力」,而 argument provenance 則是「這次操作實際使用的參數從哪裡來」。三個資訊放在一起,runtime 才有可能從單純的工具授權進一步做到參數層級的判斷。


小結

這次測試最後留下來的數字其實不是最重要的。S1 和 S2 都有自己的問題,而且結果高度依賴 UNKNOWN 的處理方式。真正讓我在意的是資料一旦被改寫,原本的來源很容易在 runtime 裡消失;來源一旦消失,後面的授權層就只能看到一個看似正常的 argument。

我原本以為把來源記下來就差不多了,實際跑完才發現,真正麻煩的是資料被改寫之後,runtime 還能不能繼續相信原本那筆來源。

Day 11 解決的是「誰能用工具」,Day 12 開始碰的是「他拿什麼去用」。

感謝大家今日份的閱讀。


上一篇
Day 11|如何安全交付自動化的審查任務給Agent?
下一篇
Day 13|在 Agent 執行期間,它是怎麼去確認授權依據的?
系列文
合法呼叫湊出的攻擊鏈:AI Agent 防禦的 30 天觀念養成16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言