aszx87410 / blog

A tech blog about Front-end, JavaScript and Security
https://blog.huli.tw
MIT License
1.07k stars 53 forks source link

我遇過的最難的 Cookie 問題 #17

Open aszx87410 opened 5 years ago

aszx87410 commented 5 years ago

簡體中文版:https://juejin.im/post/5c257ab0e51d4550442a66d9

前言

幾個禮拜前我在工作上碰到了一些跟 Cookie 有關的問題,在這之前,我原本想說:Cookie 不就那樣嘛,就算有些屬性不太熟悉,上網找一下資料就好了,哪有什麼跟 Cookie 有關的難題?

然而事實證明我錯了。我還真的碰到了一個讓我解超久的 Cookie 問題。

相信看到這邊,很多人應該躍躍欲試了,那我就先來考一下大家:

什麼情形下,Cookie 會寫不進去?

像是語法錯誤那種顯而易見的就不用說了,除此之外你可能會答說:寫完全不同 domain 的 Cookie。例如說你的網頁在 http://a.com 卻硬要寫 http://b.com 的 Cookie,這種情形當然寫不進去。

或者,你可能會回答:不在 https 卻想加上 Secure flag 的 Cookie。
沒錯,像是這種情形也會寫不進去。

除了這些,你還能想到什麼嗎?

如果想不太到,那就聽我娓娓道來吧!

悲劇的開始

在一個月前我寫了一篇跟 CSRF 有關的文章(讓我們來談談 CSRF),正是因為工作上需要實作 CSRF 的防禦,所以趁機研究了一下。簡單來說,就是要在 Cookie 設置一個 csrftoken

可是那天我卻發現,我怎麼寫都寫不進去。

我的測試網站的網址是:http://test.huli.com,拿來寫 Cookie 的 script 是:

document.cookie = "csrftoken=11111111; expires=Wed, 29 Mar 2020 10:03:33 GMT; domain=.huli.com; path=/"

我就只是想對.huli.com寫一個名稱是csrftoken的 Cookie。而我碰到的問題,就是怎麼寫都寫不進去。

這段語法完全沒有問題,我檢查過好幾遍了,但就是不知道為什麼寫不進去。我們開頭講的那幾種 case 這邊都完全沒碰到。這只是一個簡單的 http 網站,而且是寫自己 domain 的 Cookie,怎麼會寫不進去?

剛開始碰到這情形,我還想說會不會是我電腦的靈異現象,在其他人的電腦上就好了,就暫時沒有管它,直到有一天 PM 跟我說:「咦,這個頁面怎麼壞了?」,我仔細檢查後才發現是因為他也寫不進去這個 Cookie,導致 server 沒有收到 csrftoken 而驗證失敗。

好了,看來現在已經確認不是我電腦上的問題了,而是大家都會這樣。可是,卻有其他人是正常的。其他人都可以,但就只有我跟 PM 兩個人不行。

幸好見過小風小浪的我知道,每次碰到這種詭異的問題,先開無痕模式再說,至少可以知道你的瀏覽器不會被其他因素給干擾。打開無痕模式之後發現,可以了,可以設定 Cookie 了。在一般情況下不行設定,但是開無痕瀏覽模式卻可以。

這就真的很奇怪了,到底為什麼不行呢?而且若是我把 Cookie 換了一個名字,叫做csrftoken2,就可以寫入了!就唯獨csrftoken這個名稱不行,可是 Cookie 總不可能有保留字這種東西吧!就算真的有,csrftoken也絕對不會是保留字。

這一切都太詭異了,到底csrftoken這個名字有什麼問題?到底為什麼寫不進去?

於是我就去拜了 Google 大神,用cookie 不能寫cookie can not setunable set cookie等等的關鍵字去搜尋,卻都一無所獲,找到的答案都跟我的情況完全不一樣。

我用 Chrome devtool 看了,明明http://test.huli.com就沒有任何的 Cookie,怎麼會寫不進去呢?

在經歷過一陣亂找資料之後,我還稍微去翻了 cookie 的 rfc:HTTP State Management Mechanism,但還是沒有找到相關資料。

最後不知道哪來的靈感,我就去 Chrome 的設定那邊檢視所有 huli.com 的 Cookie,並且一個一個看過之後刪掉。刪完之後,就可以正常寫入 Cookie 了。

仔細想想其實還滿合理的,畢竟無痕模式可以,就代表是以前做的一些事情會影響到寫 Cookie 這件事,再經由刪除 Cookie 就可以確認問題一定是出在其他有關的 Domain 身上,推測是其他 Domain 做了一些事情,才會造成 http://test.huli.com 沒辦法寫入 Cookie。

後來我回想起剛剛刪掉的那幾個 Cookie,發現存在一個也叫做csrftoken的同名 cookie。

撥雲見日

難得讓我找到了一點線索,當然要跟著這條線索繼續查下去。

回想了一下,發現是另外一個負責後台管理的網站叫做:https://admin.huli.com寫的,因為是用 django的關係,所以開啟 CSRF 防護之後預設的 Cookie 名稱就是csrftoken

仔細再用 Chrome devtool 看了一下,這個 Cookie 設置了Secure,Domain是 .admin.huli.com。看起來也沒什麼異狀。

然而,在拜訪這個網站之後,我再試著去 http://test.huli.com,發現又沒辦法寫入 Cookie 了,甚至原本的 Cookie 也離奇地消失了。

太棒了!看來我離真相越來越近了!

我把這個.admin.huli.com的同名 Cookie 刪掉之後,去拜訪我自己的http://test.huli.com,發現一切都正常。Cookie 可以正常寫入。

看來答案很明顯了,那就是:

只要.admin.huli.com的那個同名 Cookie 存在,http://test.huli.com就沒辦法對.huli.com寫入同名的 Cookie。

解法其實到這邊就很明顯了,第一個是改一個 Cookie 名稱,第二個是改一個 Domain。

有關於第二個解法,還記得我們在 http://test.huli.com 是寫入 .huli.com 這個 Domain 的 Cookie 嗎?只要改成寫入 .test.huli.com 這個 Domain,一樣可以正常運作。

所以若是講得更詳細一點,這個寫不進去 Cookie 的問題就發生在:

當有一個 Domain 為.admin.huli.com並設置成Secure的 Cookie 已經存在的時候,http://test.huli.com就沒辦法對.huli.com寫入同名的 Cookie。

在大概確認問題以後,我就開始調整各個變因,看能不能查出到底是哪一個環節出了問題,最後我發現兩個重點:

  1. 其實只有 Chrome 不能寫,Safari, Firefox 都可以
  2. Secure 這個 flag 沒有設置的話,就可以寫

深入追查

既然有了只有 Chrome 會發生這種情形的這個有力線索,就可以循著這條線繼續追查下去,那怎麼追查呢?

沒錯,就是最簡單直接的方法:去找 Chromium 的原始碼!

以前看過很多文章都是查問題查一查最後查到 Source code 去,終於輪到我也有這一天了。可是 Chromium 的原始碼這麼一大包,該如何找起呢?

於是我決定先 Google:chromium cookie,在第一筆搜尋結果發現了很有幫助的資料:CookieMonster。這篇文章有詳細說明了 Chromium 的 Cookie 機制是怎麼運作的,並且說明核心就是一個叫做 CookieMonster 的東西。

再來就可以直接去看 Source code 了,可以在 /net/cookies 找到 cookie_monster.cc

還記得剛剛發現的問題重點之一,推測是跟Secure這個 flag 有關,所以直接用 Secure 當關鍵字下去搜尋,可以在中間的部分發現一個 DeleteAnyEquivalentCookie 的 function,以下節錄部分原始碼,1146 行到 1173 行:

// If the cookie is being set from an insecure scheme, then if a cookie
// already exists with the same name and it is Secure, then the cookie
// should *not* be updated if they domain-match and ignoring the path
// attribute.
//
// See: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-alone
if (cc->IsSecure() && !source_url.SchemeIsCryptographic() &&
    ecc.IsEquivalentForSecureCookieMatching(*cc)) {
  skipped_secure_cookie = true;
  histogram_cookie_delete_equivalent_->Add(
      COOKIE_DELETE_EQUIVALENT_SKIPPING_SECURE);
  // If the cookie is equivalent to the new cookie and wouldn't have been
  // skipped for being HTTP-only, record that it is a skipped secure cookie
  // that would have been deleted otherwise.
  if (ecc.IsEquivalent(*cc)) {
    found_equivalent_cookie = true;
    if (!skip_httponly || !cc->IsHttpOnly()) {
      histogram_cookie_delete_equivalent_->Add(
          COOKIE_DELETE_EQUIVALENT_WOULD_HAVE_DELETED);
    }
  }
} 

這邊很貼心的幫你加上了註釋,說是:

如果有個 cookie 是來自 insecure scheme,並且已經存在一個同名又設置為 Secure 又 domain-match 的 cookie 的話,這個 cookie 就不該被設置

雖然不太理解 domain-match 指的到底是怎樣才算 match,但看來我們碰到的寫不進去 Cookie 的問題就是在這一段發生的。而且還有貼心附上參考資料:https://tools.ietf.org/html/draft-ietf-httpbis-cookie-alone 標題為:「Deprecate modification of 'secure' cookies from non-secure origins」。

內容不長,很快就可以看完,以下節錄其中一小段:

Section 8.5 and Section 8.6 of [RFC6265] spell out some of the
drawbacks of cookies' implementation: due to historical accident,
non-secure origins can set cookies which will be delivered to secure
origins in a manner indistinguishable from cookies set by that origin
itself.  This enables a number of attacks, which have been recently
spelled out in some detail in [COOKIE-INTEGRITY].

附註中的參考資料是這個:Cookies Lack Integrity: Real-World Implications,裡面有附一段二十幾分鐘的影片,可以看一看,看完之後就會知道為什麼不能寫入了。

如果你還沒看,這邊可以幫大家做一個總結。要知道為什麼剛開始那個 case 不能寫入 Cookie,可以先想想看如果可以寫入,會發生什麼事情。

假如 http://test.huli.com 成功寫入 .huli.comcsrftoken 這個 cookie 的話,對 http://test.huli.com 似乎沒什麼影響,就多帶一個 Cookie 上去,看起來合情合理。

可是呢,卻對 https://admin.huli.com 有些影響。

原本 .admin.huli.com 並且設置為 Secure 的 Cookie 還是會在,但現在多了個 .huli.com 又是同名的 Cookie。當 https://admin.huli.com 送 request 的時候,就會把這兩個 Cookie 一併帶上去。所以 Server 收到的時候可能會是這樣:

csrftoken=cookie_from_test_huli_com; csrftoken=cookie_from_admin_huli_com

但碰到同名 Cookie 的時候,很多人都會只取第一個處理,所以 Server side 收到的 csrftoken 就會是 cookie_from_test_huli_com

意思就是說,儘管你在 https://admin.huli.comSecure 的方式寫了一個 Cookie,卻被其他不安全的來源(http://test.huli.com)給覆蓋過去了!

那蓋掉 Cookie 可以做什麼呢?舉幾個上面參考資料給的例子(但我不確定有沒有理解錯誤,有錯的話請指正),第一個是 Gmail 的視窗不是分成兩部分嗎,一部分是信箱,另外一部分是 Hangouts。攻擊者可以利用上面講的手法把原來使用者的 cookie 蓋掉,換成自己的 session cookie,可是因為 Hangouts 跟 Gmail 本身的 domain 不一樣,所以 Gmail 還是使用者的帳號,Hangouts 卻已經變成攻擊者的帳號了。

被攻擊的人就很有可能在不知情的狀況下利用攻擊者的帳號來發送訊息,攻擊者就可以看到那些訊息了。

第二個例子是某間銀行網站,假如在使用者要新增信用卡的時候把 session cookie 換成攻擊者的,那這張信用卡就新增到攻擊者的帳戶去了!

大概就是這樣,總之都是透過把原本的 cookie 遮蔽住,讓 server side 使用新的 cookie 的攻擊方法。

總結

我一開始碰到這個問題的時候真的滿苦惱的,因為怎麼想都想不到為什麼一個語法完全沒錯的指令沒辦法寫入 Cookie,而且https://admin.huli.com這個網站我平常也很少用到,根本不會想到是它的問題。

但這次把問題解掉之後重新回來看,其實過程中就有一些蛛絲馬跡可循,例如說可以透過「清掉 Cookie 就沒事」這點得知應該是跟其他 Cookie 有干擾,也可以從別的瀏覽器可以寫入這點得知應該是 Chrome 的一些機制。

過程中的每個線索都會帶你找到新的路,只要堅持走下去,一定能成功闖出迷宮。

yygmind commented 5 years ago

您好,我可以转载这篇文章到公众号【高级前端进阶】吗? 我会声明原作者和本文链接。谢谢!

aszx87410 commented 5 years ago

@yygmind 久仰!之前有看過您的 blog,很榮幸能被轉載,沒問題!

foolseward commented 5 years ago

666,更6的是还去翻了chrome的源码

zhoucumt commented 5 years ago

这方面来说,chrome是不是比safari和firefox更加的严谨?

yerled commented 5 years ago

如果我自己遇到这个问题,最后肯定只能简单了解到chrome存在这样的机制,而不会去探索源码去深度理解浏览器是如何设定的,您的这种探索精神以及学习方式确实很赞!

另外,如果后面那些攻击场景成立,那其他浏览器岂不是存在风险了吗?

yygmind commented 5 years ago

@yygmind 久仰!之前有看過您的 blog,很榮幸能被轉載,沒問題!

谢谢!

sunmaobin commented 5 years ago

惊心动魄~😁

BigKongfuPanda commented 5 years ago

怎么都是繁体字啊??能否弄一版简体字呢??

aszx87410 commented 5 years ago

@zhoucumt @yerled 我剛查了一下,發現 Firefox 也在大約兩年前修正這個行為了:https://bugzilla.mozilla.org/show_bug.cgi?id=976073,safari 的話我就不確定了

至於沒有修正的瀏覽器的確會有風險沒錯,但要使攻擊成立需要滿多的條件,風險似乎沒有到那麼高。

@BigKongfuPanda 不好意思啊,還是以自己習慣的寫作方式為主,你找個繁轉簡的網站把文章丟進去就行了

donghaohao commented 5 years ago

第一眼看到 huli 让我想起了《硅谷》里的 Hooli

aszx87410 commented 5 years ago

@BigKongfuPanda 突然想起來我有轉成簡體中文發在掘金上... https://juejin.im/post/5c257ab0e51d4550442a66d9

zhoucumt commented 5 years ago

@BigKongfuPanda chrome可以把繁体中文翻译成简体中文的

gongph commented 5 years ago

怎么都是繁体字啊??能否弄一版简体字呢??

鼠标右键 -> 翻译成中文简体

leowang1028 commented 5 years ago

探索精神值得学习,赞一个

HarborZeng commented 5 years ago

I want to say 666, I have to yell 666.

molysama commented 5 years ago

很有探索精神

8pig commented 5 years ago

看了 csrf 受益匪浅

Yoefs commented 5 years ago

很厉害

tedpan commented 5 years ago

所以结论是 :chrome下,对某domain设置了secure的cookie后,无法再写入该domain的顶级(即.xx.com)且没有设置secure的cookie? 我可以这样理解吗?

aszx87410 commented 5 years ago

@tedpan 是的,差不多就是這樣,然後 cookie 要同名

yangnianbing commented 5 years ago

如果后设置的cookie也是secure的,是否就可以覆盖了呢?

aszx87410 commented 5 years ago

@yangnianbing 如果後設置的也是 secure,那應該就可以覆蓋掉

HelloChunWei commented 5 years ago

我幾年前追蹤「TechBridge 技術共筆部落格」時,就非常喜歡 @aszx87410 你的文章,

當我看到這篇時我也想說「我應該是不會遇到這麼困難的cookie的問題吧?」 但現實直接給了我一拳 囧~

想請問一下 @aszx87410 或其他大神, 各位有遇到在 ios 上面的瀏覽器(chrome, safari)上發出request時會不帶cookies的情況嗎??

情境如下: user 登入後,我在server端(nodeJs + express)我會 response.setheader( 'set-cookie', 'foo=bar' )設定cookies,當開啟網頁發出 request 時,都會帶到 foo=bar 的cookie

BUT!!! 在ios 端 (ios.12) 不管是 chrome safari 都不會帶到 foo=bar 的cookie(登入之後,如果把chrome APP 整個刷掉重開,再次開啟網站,發出 request 時不會帶到cookie,但重新設定 cookie 且不刷掉 chrome 只重新整理網頁,request是會帶我設定的cookie)

在 android 是不會有這情況發生~

另外還有一個很弔詭的是,如果cookie 是利用 document.cookie 設定cookie時,在 ios 端(不管是chrome 或 safari)是會帶cookie的(囧)

不知道 各位大神有沒有遇過類似狀況,或指點我一下,該往哪個哪個方向google (google了 ios 12 cookie 以及其他的組合都沒找到相關討論的文章)

再次感謝正在閱讀的你們,願意花時間看完~~

HelloChunWei commented 5 years ago

我幾年前追蹤「TechBridge 技術共筆部落格」時,就非常喜歡 @aszx87410 你的文章,

當我看到這篇時我也想說「我應該是不會遇到這麼困難的cookie的問題吧?」 但現實直接給了我一拳 囧~

想請問一下 @aszx87410 或其他大神, 各位有遇到在 ios 上面的瀏覽器(chrome, safari)上發出request時會不帶cookies的情況嗎??

情境如下: user 登入後,我在server端(nodeJs + express)我會 response.setheader( 'set-cookie', 'foo=bar' )設定cookies,當開啟網頁發出 request 時,都會帶到 foo=bar 的cookie

BUT!!! 在ios 端 (ios.12) 不管是 chrome safari 都不會帶到 foo=bar 的cookie(登入之後,如果把chrome APP 整個刷掉重開,再次開啟網站,發出 request 時不會帶到cookie,但重新設定 cookie 且不刷掉 chrome 只重新整理網頁,request是會帶我設定的cookie)

在 android 是不會有這情況發生~

另外還有一個很弔詭的是,如果cookie 是利用 document.cookie 設定cookie時,在 ios 端(不管是chrome 或 safari)是會帶cookie的(囧)

不知道 各位大神有沒有遇過類似狀況,或指點我一下,該往哪個哪個方向google (google了 ios 12 cookie 以及其他的組合都沒找到相關討論的文章)

再次感謝正在閱讀的你們,願意花時間看完~~

更新 ~ 設定了 Max-Age 之後問題就解決了~ 至於 document.cookie 為何可以讀取呢~~ 後來我翻了一下系統的陳年老code.... 前輩在設定 document.cookie 的時候把期限設為一年後 (囧)

總之,問題算是明朗化了,接下來的研究範圍可能會朝「為何android 以及 macbook 的瀏覽器,即使沒特別設定 Max-Age 也還是會儲存著,以及了解ios裝置對 cookie 儲存模式」方向前進

感謝大家~ (鬧了一齣笑話

aszx87410 commented 5 years ago

@HelloJunWei

可以先來看一下 cookie rfc 怎麼說這個狀況:

If a cookie has neither the Max-Age nor the Expires attribute, the user agent will retain the cookie until "the current session is over" (as defined by the user agent).

就是一般我們稱做的:session-cookie,在一個 session 結束掉之後就會被刪掉的 cookie。其實用起來會跟 sessionStorage 有點像,但什麼叫做「一個 session 結束掉」,是每個瀏覽器自己定義的。

依據你的說法,在 iOS 的 chrome 與 safari 上面,只要關掉 app,session 就結束了,所以當你重新打開 app 的時候 cookie 就不見了。而重新整理的話則是還會在,這點其實跟 sessionStorage 的表現是一樣的。

那在電腦上呢?幸好我們可以找 chromium 的原始碼來看,出現在這邊一個叫做 CanonExpiration 的函式裡面:

// static
Time CanonicalCookie::CanonExpiration(const ParsedCookie& pc,
                                      const Time& current,
                                      const Time& server_time) {
  // First, try the Max-Age attribute.
  uint64_t max_age = 0;
  if (pc.HasMaxAge() &&
#ifdef COMPILER_MSVC
      sscanf_s(
#else
      sscanf(
#endif
             pc.MaxAge().c_str(), " %" PRIu64, &max_age) == 1) {
    return current + TimeDelta::FromSeconds(max_age);
  }
  // Try the Expires attribute.
  if (pc.HasExpires() && !pc.Expires().empty()) {
    // Adjust for clock skew between server and host.
    base::Time parsed_expiry =
        cookie_util::ParseCookieExpirationTime(pc.Expires());
    if (!parsed_expiry.is_null())
      return parsed_expiry + (current - server_time);
  }
  // Invalid or no expiration, persistent cookie.
  return Time();
}

看最下面的註解就好:Invalid or no expiration, persistent cookie,沒有理解錯的話電腦版的 chromium 會把這個 cookie 永久保存(?),雖然感覺滿怪的就是了XDD 因為我原本以為也會立刻把 cookie 丟掉之類的

因為覺得這邊可能事有蹊蹺所以繼續往下深追,先追去 base/time.h,確定了 Time() 會回傳 NULL:

// Contains the NULL time. Use Time::Now() to get the current time.
explicit Time() : us_(0) {
}

然後在 CookieMonster.c 裡面,會使用 IsExpired 這個方法來判定是否過期:

// If the cookie is expired, delete it.
if (cc->IsExpired(current)) {
  InternalDeleteCookie(curit, true, DELETE_COOKIE_EXPIRED);
  continue;
}

這個方法存在於 canonical_cookie.h 裡面:

bool IsExpired(const base::Time& current) const {
  return !expiry_date_.is_null() && current >= expiry_date_;
}

代表如果 expirydate 是 null,就永遠不會過期。所以驗證了 Invalid or no expiration, persistent cookie 這一句話。

HelloChunWei commented 5 years ago

@aszx87410 後來確定問題之後,有看到這份RFC檔案 但老實說,原本大多數情形都在MDN找尋我要的文件,完全沒想到過我會有一天要去看RFC文件XDD 經過這一事後算長了一智了。

另外我有找到chrome 存cookie的檔案在(macOS):/Library/Application Support/Google/Chrome/Default/Cookies 格式為sqlite ,用檢視sqlite軟體開啟的話,確實能看到我設定的cookie。

另一點讓我訝異的是,macbook 的 safari 也是採取 chrome 的方式處理 cookies (我並沒有特別設定 safari 是否要阻擋cookies )

而在ios 系統上,chrome safari 以及 firefox 都是關掉 app 都會刷掉cookies (沒設定Max-Age的話)

我原本預設以為同樣的瀏覽器應該會有同樣的處理方式(電腦版chrome 不會清除 cookie,所以IOS chrome 也不會清除) 但實際測試的結果是完全不一樣的,而在 android 處理方式也是和電腦版一樣,不會清除cookie (沒設定Max-Age的的話)

經過這一次之後,深深覺得自己找文件的功力仍需加強加強。

HelloChunWei commented 5 years ago

@aszx87410 後來確定問題之後,有看到這份RFC檔案 但老實說,原本大多數情形都在MDN找尋我要的文件,完全沒想到過我會有一天要去看RFC文件XDD 經過這一事後算長了一智了。

另外我有找到chrome 存cookie的檔案在(macOS):/Library/Application Support/Google/Chrome/Default/Cookies 格式為sqlite ,用檢視sqlite軟體開啟的話,確實能看到我設定的cookie。

另一點讓我訝異的是,macbook 的 safari 也是採取 chrome 的方式處理 cookies (我並沒有特別設定 safari 是否要阻擋cookies )

而在ios 系統上,chrome safari 以及 firefox 都是關掉 app 都會刷掉cookies (沒設定Max-Age的話)

我原本預設以為同樣的瀏覽器應該會有同樣的處理方式(電腦版chrome 不會清除 cookie,所以IOS chrome 也不會清除) 但實際測試的結果是完全不一樣的,而在 android 處理方式也是和電腦版一樣,不會清除cookie (沒設定Max-Age的的話)

經過這一次之後,深深覺得自己找文件的功力仍需加強加強。

稍微找了一下 chrome 在 ios 的設計,找到這一份文件,大致上是講解了chrome 在 ios 上運作cookie的方式。 若我的理解沒錯的話: ios chrome APP cookie 運作方式是遵循 NSHTTPCookieStorge 的方式去運作,如果沒有設定過期時間的話,根據expiresDate 關掉APP 則會刷掉cookie。

有錯的話還煩請指正~

aszx87410 commented 5 years ago

@HelloJunWei 看起來滿對的 👍