# Missing redaction of hardware-keyring data in persistAllKeyrings leads to plaintext disclosure of GridPlus pairing credentials

TLDR: GridPlus hardware wallet pairing credentials are stored in plaintext. Local malware could read them, connect to the hardware wallet bypassing the pairing process, and initiate interaction requests. We received a $50 bug bounty for this report.

This bug was found autonomously using V12 by xia0o0o0o of the [V12](https://v12.sh) security team.

## Summary

Rabby's `KeyringService.persistAllKeyrings()` writes every keyring's serialized state twice, once into an encrypted `vault` and once into a plaintext `unencryptedKeyringData` array that is mirrored into the extension's observable storage. The only types excluded from the plaintext copy are the simple private-key and HD-mnemonic keyrings listed in `UNENCRYPTED_IGNORE_KEYRING`, so every other registered type, including the GridPlus Hardware (Lattice) keyring, is copied without redaction. Because the Lattice keyring serializes a `creds` object that holds the device pairing `deviceID`, `password`, and optional `endpoint`, those pairing secrets end up stored in cleartext next to the encrypted vault, where anyone who can read the extension's persisted storage recovers them directly without knowing the wallet password.

## Root Cause

The plaintext persistence policy treats all serialized keyring data as non-secret unless its type appears in a short ignore list, and that list covers only the two software keyrings.

```typescript
// src/background/service/keyring/index.ts:57-60 (pre-fix)
const UNENCRYPTED_IGNORE_KEYRING = [
  KEYRING_TYPE.SimpleKeyring,
  KEYRING_TYPE.HdKeyring,
];
```

The Lattice keyring, by contrast, is registered as a persisted keyring type, so it travels the same persistence path as every other non-ignored keyring.

```typescript
// src/background/service/keyring/index.ts:62-77 (pre-fix)
export const KEYRING_SDK_TYPES = {
  SimpleKeyring,
  HdKeyring,
  BitBox02Keyring,
  TrezorKeyring,
  LedgerBridgeKeyring,
  OnekeyKeyring,
  WatchKeyring,
  WalletConnectKeyring,
  GnosisKeyring,
  LatticeKeyring,        // <-- GridPlus Hardware
  KeystoneKeyring,
  CoboArgusKeyring,
  CoinbaseKeyring,
  EthImKeyKeyring,
};
```

When state is persisted, `persistAllKeyrings()` serializes every keyring and then builds `unencryptedKeyringData` by copying any serialized keyring whose `type` is not in the ignore list. It performs no field-level redaction before writing the result into the observable store next to the encrypted vault.

```typescript
// src/background/service/keyring/index.ts:964-992 (pre-fix)
let hasEncryptedKeyringData = false;
const unencryptedKeyringData = serializedKeyrings
  .map(({ type, data }) => {
    if (!UNENCRYPTED_IGNORE_KEYRING.includes(type as any)) {
      return { type, data };          // GridPlus `creds` copied verbatim into plaintext
    }

    // maybe empty keyring
    // TODO: maybe need remove simple keyring if empty
    if (type === KEYRING_TYPE.SimpleKeyring && !data.length) {
      return undefined;
    }

    hasEncryptedKeyringData = true;
    return undefined;
  })
  .filter(Boolean) as KeyringSerializedData[];

const encryptedString = await passwordEncrypt({
  data: serializedKeyrings,
  password: this.password,
  persisted: true,
});

this.store.updateState({
  vault: encryptedString,
  unencryptedKeyringData,             // <-- plaintext, persisted to extension storage
  hasEncryptedKeyringData,
});
```

The data copied for a GridPlus keyring is whatever the Lattice keyring's `serialize()` returns, which includes the `creds` object that holds the device pairing secret:

```typescript
// shape of LatticeKeyring.serialize() (@rabby-wallet/eth-lattice-keyring@1.2.4)
{
  creds: {
    deviceID: 'device-123',
    password: 'pairing-secret',
    endpoint: 'https://lattice.example',
  },
  accounts: [ '0x1111111111111111111111111111111111111111' ],
  accountIndices: [ 0 ],
  accountOpts: [ { walletUID: 'wallet-1', hdPath: "m/44'/60'/0'/0/x" } ],
  walletUID: 'wallet-1',
  appName: 'Rabby',
  name: undefined,        // legacy, deprecated
  network: null,
  page: 0,
  hdPath: "m/44'/60'/0'/0/x",
}
```

The result is that a GridPlus keyring's pairing credentials are written to disk in cleartext, while the rest of the keyring material in the `vault` stays encrypted behind the wallet password.

## Impact

An attacker who can read the extension's persisted keyring state can pull the GridPlus pairing `deviceID`, `password`, and `endpoint` straight out of `unencryptedKeyringData`. Plenty of ordinary situations give that read access: local file access to the browser profile, a storage-disclosure bug, a malicious or over-permissioned extension, synced or backed-up profile data, or forensic recovery of the device. Those three values are what the Lattice SDK needs to open a session against the paired device or its endpoint.

Reading the vault requires the wallet password, but reading the pairing credentials does not, because they sit in cleartext beside it. The credentials are rewritten to plaintext on every `persistAllKeyrings()` call for as long as a GridPlus keyring exists in the wallet, so locking the wallet or restarting the extension does not clear them.

## References

All line references are to RabbyHub/Rabby at commit [`c9ff0cc`](https://github.com/RabbyHub/Rabby/tree/c9ff0cc1a138b0deadc5ade5445a1b68ba43a8ba), the last revision before the fix. The ignore list that the policy consults is defined in [`index.ts` L57-L60](https://github.com/RabbyHub/Rabby/blob/c9ff0cc1a138b0deadc5ade5445a1b68ba43a8ba/src/background/service/keyring/index.ts#L57-L60) and excludes only the simple and HD keyrings, while the GridPlus Lattice keyring is registered as a persisted type in the [`KEYRING_SDK_TYPES` map at L62-L77](https://github.com/RabbyHub/Rabby/blob/c9ff0cc1a138b0deadc5ade5445a1b68ba43a8ba/src/background/service/keyring/index.ts#L62-L77). The plaintext copy is assembled and written in [`persistAllKeyrings()` at L943-L992](https://github.com/RabbyHub/Rabby/blob/c9ff0cc1a138b0deadc5ade5445a1b68ba43a8ba/src/background/service/keyring/index.ts#L943-L992), and the credentials it copies come from the [`LatticeKeyring`](https://github.com/RabbyHub/Rabby/blob/c9ff0cc1a138b0deadc5ade5445a1b68ba43a8ba/src/background/service/keyring/eth-lattice-keyring/eth-lattice-keyring.ts) wrapper, which inherits `serialize()` from `@rabby-wallet/eth-lattice-keyring@1.2.4`. Rabby fixed the issue on 17 June 2026 in [PR #3797](https://github.com/RabbyHub/Rabby/pull/3797), which adds a `sanitizeKeyringDataForUnencryptedStore` sanitizer and a one-time migration that scrubs already-persisted state.

## Proof of Concept

The PoC exercises the real public path `KeyringService.addNewKeyring('GridPlus Hardware', …) → addKeyring() → persistAllKeyrings()` and asserts that GridPlus pairing `creds` (`deviceID`, `password`, `endpoint`) land in both the in-memory store and the persisted `keyringState.unencryptedKeyringData` written through `browser.storage.local.set`. Unrelated keyring/dependency imports are replaced with focused Jest stubs so the keyring service can run in isolation; the Lattice keyring stub serializes a realistic `creds` object exactly as the real GridPlus dependency does.

### Prerequisites

The PoC requires Node.js and Yarn (Berry) matching the Rabby repository toolchain, a checkout of [RabbyHub/Rabby](https://github.com/RabbyHub/Rabby) at commit `c9ff0cc` (the pre-fix revision), and Jest.

### Step 1: Install dependencies

```bash
#!/bin/bash
set -e

# install dependencies
yarn install --immutable
```

### Step 2: Add the PoC test

Place [`gridplus-plaintext-creds-poc.test.ts`](./gridplus-plaintext-creds-poc.test.ts) under the repo's `__tests__/` tree (the relative `../../src/...` mock paths assume a two-level-deep location, e.g. `__tests__/utils/`). The full test is reproduced below for reference:

````typescript
import browser from 'webextension-polyfill';

const contactState: Record<string, any> = {};

const makeEmptyKeyring = (type: string) => {
  return class {
    static type = type;
    type = type;
    async getAccounts() {
      return [];
    }
    async serialize() {
      return [];
    }
    async deserialize() {
      return undefined;
    }
  };
};

jest.mock('consts', () => ({
  KEYRING_TYPE: {
    HdKeyring: 'HD Key Tree',
    SimpleKeyring: 'Simple Key Pair',
    WatchAddressKeyring: 'Watch Address',
    WalletConnectKeyring: 'WalletConnect',
    GnosisKeyring: 'Gnosis',
    CoboArgusKeyring: 'CoboArgus',
    CoinbaseKeyring: 'Coinbase',
  },
  EVENTS: {
    broadcastToUI: 'broadcastToUI',
    PERSIST_KEYRING: 'PERSIST_KEYRING',
    WALLETCONNECT: {
      INIT: 'INIT',
      INITED: 'INITED',
      TRANSPORT_ERROR: 'TRANSPORT_ERROR',
      STATUS_CHANGED: 'STATUS_CHANGED',
      SESSION_STATUS_CHANGED: 'SESSION_STATUS_CHANGED',
      SESSION_ACCOUNT_CHANGED: 'SESSION_ACCOUNT_CHANGED',
      SESSION_NETWORK_DELAY: 'SESSION_NETWORK_DELAY',
    },
  },
  KEYRING_CLASS: {
    PRIVATE_KEY: 'Simple Key Pair',
    MNEMONIC: 'HD Key Tree',
    WATCH: 'Watch Address',
    WALLETCONNECT: 'WalletConnect',
    GNOSIS: 'Gnosis',
    CoboArgus: 'CoboArgus',
    Coinbase: 'Coinbase',
    HARDWARE: {
      BITBOX02: 'BitBox02 Hardware',
      TREZOR: 'Trezor Hardware',
      LEDGER: 'Ledger Hardware',
      ONEKEY: 'Onekey Hardware',
      GRIDPLUS: 'GridPlus Hardware',
      KEYSTONE: 'QR Hardware Wallet Device',
      IMKEY: 'imKey Hardware',
    },
  },
  HARDWARE_KEYRING_TYPES: {
    GridPlus: { type: 'GridPlus Hardware', brandName: 'GridPlus' },
  },
}));

jest.mock('background/utils', () => ({
  normalizeAddress: (address: string) =>
    address?.startsWith('0x') ? address.toLowerCase() : `0x${address}`.toLowerCase(),
  setPageStateCacheWhenPopupClose: jest.fn(),
  hasWalletConnectPageStateCache: jest.fn(() => false),
  isSameAddress: (a: string, b: string) => a?.toLowerCase() === b?.toLowerCase(),
}));

jest.mock('../../src/background/service/preference', () => ({
  __esModule: true,
  default: {
    getPopupOpen: jest.fn(() => false),
    getHiddenAddresses: jest.fn(() => []),
  },
}));

jest.mock('../../src/background/service/i18n', () => ({
  __esModule: true,
  default: {
    t: (key: string) => key,
  },
}));

jest.mock('../../src/background/service/contactBook', () => ({
  __esModule: true,
  default: {
    init: jest.fn(async () => undefined),
    getContactByAddress: jest.fn((address: string) =>
      contactState[address.toLowerCase()]
    ),
    addAlias: jest.fn(({ address, name }: { address: string; name: string }) => {
      contactState[address.toLowerCase()] = {
        address: address.toLowerCase(),
        name,
        isAlias: true,
        isContact: false,
      };
    }),
    updateAlias: jest.fn((entry: any) => {
      contactState[entry.address.toLowerCase()] = {
        ...entry,
        address: entry.address.toLowerCase(),
      };
    }),
    getCacheAlias: jest.fn(() => undefined),
    removeCacheAlias: jest.fn(() => undefined),
  },
}));

jest.mock('../../src/background/service/uninstalled', () => ({
  __esModule: true,
  default: {
    setWalletByKeyringType: jest.fn(),
  },
}));

jest.mock('@/eventBus', () => ({
  __esModule: true,
  default: {
    emit: jest.fn(),
    addEventListener: jest.fn(),
  },
}));

jest.mock('@/utils/account', () => ({
  filterKeyringData: jest.fn((v) => v),
  generateAliasName: jest.fn(() => 'alias'),
}));

jest.mock('@/utils/walletconnect', () => ({
  GET_WALLETCONNECT_CONFIG: jest.fn(() => ({})),
  allChainIds: [],
}));

jest.mock('@/utils/chain', () => ({
  getChainList: jest.fn(() => []),
}));

jest.mock('../../src/background/service/keyring/bridge', () => ({
  getKeyringBridge: jest.fn(async () => undefined),
  hasBridge: jest.fn(async () => false),
}));

jest.mock('../../src/background/service/keyring/display', () => ({
  __esModule: true,
  default: class DisplayKeyring {},
}));

jest.mock('@sentry/browser', () => ({
  captureException: jest.fn(),
}));

jest.mock('@rabby-wallet/eth-simple-keyring', () => makeEmptyKeyring('Simple Key Pair'));
jest.mock('@rabby-wallet/eth-hd-keyring', () => makeEmptyKeyring('HD Key Tree'));
jest.mock('@rabby-wallet/eth-watch-keyring', () => makeEmptyKeyring('Watch Address'));
jest.mock('@rabby-wallet/eth-coinbase-keyring', () => makeEmptyKeyring('Coinbase'));
jest.mock('@rabby-wallet/eth-trezor-keyring', () => makeEmptyKeyring('Trezor Hardware'));
jest.mock('@rabby-wallet/eth-walletconnect-keyring', () => ({
  WalletConnectKeyring: makeEmptyKeyring('WalletConnect'),
}));

jest.mock('../../src/background/service/keyring/eth-bitbox02-keyring/eth-bitbox02-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('BitBox02 Hardware'),
}));
jest.mock('../../src/background/service/keyring/eth-ledger-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('Ledger Hardware'),
}));
jest.mock('../../src/background/service/keyring/eth-onekey-keyring/eth-onekey-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('Onekey Hardware'),
}));
jest.mock('../../src/background/service/keyring/eth-keystone-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('QR Hardware Wallet Device'),
}));
jest.mock('../../src/background/service/keyring/eth-cobo-argus-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('CoboArgus'),
}));
jest.mock('../../src/background/service/keyring/eth-gnosis-keyring', () => ({
  __esModule: true,
  default: makeEmptyKeyring('Gnosis'),
  TransactionBuiltEvent: 'TransactionBuiltEvent',
  TransactionConfirmedEvent: 'TransactionConfirmedEvent',
}));
jest.mock('../../src/background/service/keyring/eth-imkey-keyring/eth-imkey-keyring', () => ({
  __esModule: true,
  EthImKeyKeyring: makeEmptyKeyring('imKey Hardware'),
}));

jest.mock('../../src/background/service/keyring/eth-lattice-keyring/eth-lattice-keyring', () => {
  class MockLatticeKeyring {
    static type = 'GridPlus Hardware';
    type = 'GridPlus Hardware';
    creds: any;
    accounts: string[];
    accountIndices: number[];
    accountOpts: any[];
    walletUID: string | undefined;
    appName: string | undefined;
    hdPath: string | undefined;

    constructor(opts: any = {}) {
      this.accounts = [];
      this.accountIndices = [];
      this.accountOpts = [];
      void this.deserialize(opts);
    }

    async deserialize(opts: any = {}) {
      this.creds = opts.creds;
      this.accounts = opts.accounts || [];
      this.accountIndices = opts.accountIndices || [];
      this.accountOpts = opts.accountOpts || [];
      this.walletUID = opts.walletUID;
      this.appName = opts.appName;
      this.hdPath = opts.hdPath;
    }

    async serialize() {
      return {
        creds: this.creds,
        accounts: this.accounts,
        accountIndices: this.accountIndices,
        accountOpts: this.accountOpts,
        walletUID: this.walletUID,
        appName: this.appName,
        hdPath: this.hdPath,
      };
    }

    async getAccounts() {
      return this.accounts;
    }
  }

  return {
    __esModule: true,
    default: MockLatticeKeyring,
  };
});

jest.mock('background/utils/password', () => ({
  passwordEncrypt: jest.fn(async ({ data }) => JSON.stringify(data)),
  passwordDecrypt: jest.fn(async ({ encryptedData }) =>
    typeof encryptedData === 'string' ? JSON.parse(encryptedData) : encryptedData
  ),
  passwordClearKey: jest.fn(async () => undefined),
}));

const { KeyringService } = require('background/service/keyring');

const password = 'password123';
const latticeCreds = {
  deviceID: 'device-123',
  password: 'pairing-secret',
  endpoint: 'https://lattice.example',
};
const latticeAccount = '0x1111111111111111111111111111111111111111';
const hdPath = "m/44'/60'/0'/0/x";

describe('GridPlus plaintext credential persistence PoC', () => {
  let keyringService: InstanceType<typeof KeyringService>;
  let persistedState: Record<string, any> = {};

  beforeEach(async () => {
    Object.keys(contactState).forEach((key) => delete contactState[key]);
    persistedState = {};
    jest.restoreAllMocks();

    jest
      .spyOn(browser.storage.local, 'set')
      .mockImplementation(async (items: Record<string, any>) => {
        persistedState = { ...persistedState, ...items };
      });

    keyringService = new KeyringService();
    keyringService.loadStore({});
    keyringService.store.subscribe((value) =>
      browser.storage.local.set({ keyringState: value })
    );
    await keyringService.boot(password);
  });

  test('public addNewKeyring persists GridPlus pairing creds in plaintext keyringState', async () => {
    await keyringService.addNewKeyring('GridPlus Hardware', {
      creds: latticeCreds,
      accounts: [latticeAccount],
      accountIndices: [0],
      accountOpts: [{ walletUID: 'wallet-1', hdPath }],
      walletUID: 'wallet-1',
      appName: 'Rabby',
      hdPath,
    });

    expect(await keyringService.getAccounts()).toEqual([latticeAccount]);
    expect(keyringService.store.getState().vault).toBeDefined();

    expect(keyringService.store.getState().unencryptedKeyringData).toEqual([
      expect.objectContaining({
        type: 'GridPlus Hardware',
        data: expect.objectContaining({
          creds: latticeCreds,
        }),
      }),
    ]);

    expect(persistedState.keyringState.unencryptedKeyringData).toEqual([
      expect.objectContaining({
        type: 'GridPlus Hardware',
        data: expect.objectContaining({
          creds: expect.objectContaining({
            deviceID: 'device-123',
            password: 'pairing-secret',
            endpoint: 'https://lattice.example',
          }),
        }),
      }),
    ]);
  });
});
````

### Step 3: Run the PoC

```bash
yarn jest __tests__/utils/gridplus-plaintext-creds-poc.test.ts
```

### Actual Execution Output

```
PASS __tests__/utils/gridplus-plaintext-creds-poc.test.ts (20.812 s)
  GridPlus plaintext credential persistence PoC
    ✓ public addNewKeyring persists GridPlus pairing creds in plaintext keyringState (7 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        21.112 s
```
