This article documents the reverse-engineering results for request signing in Xiaohongshu Android 9.43.1 (build 9431801), covering deviceId, MUA, SIG, S1, Shield, main_hmac, SSK, registration payloads, and device profiling. MUA uses X25519, AES-CBC, zlib, and JSON; SIG is based on canonicalized SHA-256 and a fixed affine transformation over GF(2); S1 uses a digest, customized AES-like CBC, a timestamp, and CRC; Shield uses customized HMAC-MD5, RC4, and optional SSK proofs; and SSK is decrypted through X25519 and AES-256-GCM. The article also explains the relationships among session state, counters, random material, and device-collection fields, and provides validation samples and implementation details. The conclusions apply only to the specified version and environment and do not represent later versions, actual OEM data collection, or server-side risk-control policies.
This article analyzes request signing in Xiaohongshu Android 9.43.1 (build 9431801), including the device identifier, MUA, SIG, S1, Shield, and the key exchange required by Shield. All constants and binary layouts described here are specific to this version.
MUA, SIG, and S1 are handled by the libtiny processing chain, while Shield and main_hmac decryption correspond to libxyass. The discussion below proceeds through input construction, cryptographic transformations, and output formats. Here, native refers to the native libraries in this version; emulator comparisons represent only the library output for the given environment inputs.
Below, || denote byte concatenation, XOR denote bitwise XOR; u32le, u32be respectively denote little-endian and big-endian encodings of 32-bit integers. Truncation in integer operations is noted separately.
This article is a reverse-engineering record of the client's signature implementation, limited to the local algorithms and message formats in Android 9.43.1 (build 9431801). The formulas and constants describe only how this version constructs request headers; they do not provide a method for unauthorized access, bypassing risk controls, or bulk scraping. Server-side policies, device environments, and later versions may all invalidate these conclusions; collection branches and real-device states not covered here are not inferred from a single successful response.
0. Overall signature flow
A content request involves the following fields:
Field
Contents and purpose
x-mini-mua
the session public key, random material, counter, and encrypted device profile
x-mini-sig
the SHA-256 digest of the canonical request string and an affine transformation of its first half
x-mini-s1
a 62-byte structure generated from the canonical string, counter, and timestamp
shield
the request HMAC and, optionally, an SSK association proof
x-mini-gid
the identity returned by server-side registration
SIG and S1 use the same canonical string, defined below as canonical:
Here, METHOD is uppercase; PATH excludes the domain; QUERY is the original string after the question mark, without further decoding or sorting and without the question mark itself; body is the bytes actually sent; SHA-256 uses 64 lowercase hexadecimal characters; and MUA retains its complete format, including the trailing period. The five items are separated by one LF byte (0x0A), with no extra line break at the end. In the expression above, \n denotes a line break, not a backslash followed by the letter n; when query is empty, the corresponding blank line is retained.
For a GET request, body is empty, and its SHA-256 is:
Shield uses a separate input: path, query, the specified request headers, and body are concatenated directly in a fixed order. The SIG, S1, and MUA headers are not included directly in the HMAC input.
A verified cold-start client flow is as follows:
This is the client's call order, not a claim that every step has a cryptographic dependency. For example, the registration request does not use Shield and does not itself depend on key64. The fields used at each stage are as follows:
Stage
MUA / SIG / S1
Shield
Response material
Obtain vfc_code
is not included on the current bootstrap path
the first call returns a 70-byte structure without main_hmac
xy-ter-str
register/android
computed using the registration body
not included
data.g
activate
computed using the form body
main_hmac present, no SSK, 70 bytes
SSK ciphertext, session
cold_start_config
computed using the final request
both main_hmac and SSK present, 118 bytes
id_token
Content request
computed using the final request
usually 118 bytes
business data
The activate response may also contain a token. In the client flow described here, even when a token already exists, cold_start_config is still requested to update it. sid and id_token enter Shield via xy-common-params; they are not independent fields in canonical.
Each response updates different state: registration saves data.g; activate decrypts main_ssk and saves data.session with the session. prefix as sid; cold_start_config reads the token from data.user_info.user_tags.id_token. Subsequent requests can use these new values only after the corresponding response has been processed successfully.
The same request headers and signature also cover the recommendation, search, and author-profile APIs. The interfaces that have been run end to end under an anonymous session include imagefeed and user/info and user/posted on edith.xiaohongshu.com, homefeed on rec.xiaohongshu.com, and search/notes on so.xiaohongshu.com. canonical uses only PATH, not the domain; across domains, only host and query change, while the signature formula remains the same.
The server has exhibited two types of session-level failure: code=-100, with msg set to 「Please log in」; and code=300011, with msg set to 「The current account is abnormal. Switch accounts and try again」. Both have occurred during consecutive requests. Retrying the same request on the same device usually still fails, while re-bootstrapping with a different device identity restores operation. These are expressions of server-side session and risk-control state; this article does not attribute them to an error in any particular signature input.
1. android_id and deviceId
deviceId uses the standard MD5 and UUID v3 version and variant-bit rules. The input is the UTF-8 bytes of the android_id text; for example, a 16-character hexadecimal string is still 16 bytes of ASCII and is not first decoded into 8 bytes.
This hashes the name bytes directly without prepending a namespace, so it cannot be replaced with a UUID v3 API that takes an arbitrary namespace.
An equivalent formulation is (h[6] & 0x3F) | 0x30 and (h[8] & 0xBF) | 0x80. The bit 4 and bit 5 retained by the former are then both set to 1, while the bit 7 retained by the latter is also set to 1, so the final result is the same.
Example:
Example:
2. x-mini-mua: session material and device profile
The MUA format is:
No spaces are inserted during concatenation. Both Base64URL segments have their trailing = removed; the final period is retained. Part1 can be decoded directly as JSON, while Part2 must first be decrypted using the session shared secret.
Session initialization first obtains 64 bytes of random material s and then a 32-byte raw X25519 scalar. These two materials are reused within a session. The request counter and device runtime state may continue to change, so reusing the session material does not mean reusing the entire MUA.
2.1 X25519 and AES material
X25519 is computed over the prime field p = 2^255 - 19 . Before the scalar enters the curve operation, it is clamped:
The raw scalar may be stored unchanged; clamping applies only to the copy supplied to the curve operation.
The base point 9 is encoded as a 32-byte little-endian value, namely 09 followed by 31 00The MUA server public key is:
SSK uses a different server public key; see Section 7. An all-zero shared secret must be treated as a failure.
the raw scalar, public key kand AES materials, respectively: multiplying the base point yields k, while X25519 with the fixed peer key yields slotA. Two sessions using different scalars both satisfy this relationship. Reconstructing Part2 with the derived key and IV rules out the possibility that the match applies only to the intermediate value from the preceding step.
native represents field elements using five 51-bit limbs. Their serialized form corresponds to the standard 32-byte little-endian output of X25519, so reproducing the protocol does not require retaining the internal limb representation.
When comparing native's intermediate buffer directly, each of the five limbs occupies one little-endian u64, for a total of 40 bytes. Let their values be h0…h4; the serialization formula is:
Here, the complete value is reduced modulo the relevant modulus rather than truncating each limb to 51 bits first; an intermediate limb may still contain an unnormalized carry. The same rule applies to k and slotA at this boundary.
Minimal implementation of the curve operations:
Splitting the session material and slotA:
2.2 Part1 fields
The key order in the current request construction is:
Field
Meaning
a
fixed string ECFAAF01
c
the integer counter for this MUA
k
the X25519 public key, as 64 lowercase hexadecimal characters
p
fixed string a
s
64 bytes of random material, represented as 128 lowercase hexadecimal characters
t
a runtime-state object, for example {"c":0,"d":0,"f":0,"s":4098,"t":0,"tt":[]}; t may internally be supplied with uptime in milliseconds
u
for non-registration requests with a non-empty OAID, "00000000" + oaid
v
fixed string 2.9.99
Serialization uses compact JSON, leaves Unicode unescaped rather than converting it to ASCII escapes, and preserves field order. The registration path omits u. Historical full-replay samples also exist without t, so replays must preserve the original field set; adding a key changes Part1 and the complete MUA.
The body.c value for a registration, MUA.c, and the counter in the S1 frame all use the same value. The first registration in the cold-start client flow uses c=2, but the counter must be managed as session state rather than hard-coded as 2 for every request.
The Part1 field set varies by platform and version. On iOS 9.47.2, the observed Part1 uses a=ECFAAF02, and also includes i and m (a millisecond timestamp), n, and other fields not covered here; the request headers also include x-mini-nsig. Before reusing the formulas in this article across versions, verify the field set first.
Part1 construction:
For content requests with a non-empty OAID, insert u ('00000000' + oaid, between t and v), while the registration path omits u.
2.3 Part2 encoding and encryption
Part2's plaintext is a device-profile JSON object. The observed baseline contains 95 fields covering the package version, build information, installation time, battery, network, storage, process state, and more.
Key order is x0,x1,x10,…, not numeric order. Only the top-level keys are reordered here; nested objects are serialized in their existing order. The textual representation of floating-point numbers and the way Unicode is escaped are also part of the input bytes, so they must remain consistent across language implementations.
PKCS#7 padding rule:
Even when the compressed output is already aligned to 16 bytes, append 16 bytes, each containing the value 0x100x10.
For reverse verification, process the data in this order: Base64URL-decode, decrypt with CBC, verify and remove PKCS#7 padding, then decompress with zlib. The compressed data has a zlib wrapper and cannot be decompressed directly as raw DEFLATE. Identical JSON does not guarantee identical compressed bytes across compressors; reproducing the stream byte for byte also requires comparing the compression stream itself.
Field values must retain their JSON types. For example, x2 is an integer version code, x17 is an integer SDK value, x43 is a network string, and x242 is an empty array;"0" and 0 are not interchangeable either. x5's "JTdCJTdE" is the Base64 encoding of the text %7B%7D, not the Base64 encoding of {}. The profile here and the registration snapshot are two independent objects.
Validation at this layer should compare the plaintext JSON, zlib bytes, CBC ciphertext, and final MUA at the same time. Verifying only that data encrypted by your implementation can be decrypted by that same implementation does not prove that it matches native behavior. With fixed session material and the original profile, the complete MUA matches the native output byte for byte.
Reproducing the encryption and reproducing device data collection are two separate problems. A synthetic profile can validate the encoding pipeline, but it cannot establish the source of every field on a real Android device.
Outer pipeline:
sorted(profile) is lexicographic string sorting, which directly produces x0, x1, x10, …the top-level key order; nested objects retain their input order.
2.4 Complete Mapping of the 95-Field Profile
The table below records the implemented profile construction. Package, build, and hardware fields come from device parameters; time and runtime-state fields can be supplied explicitly. Fixed values, time offsets, and seed-derived values are offline synthesis strategies, not formulas for collecting data from a real device.
Real-device confirmation (Xiaomi MI 6 / Lineage 15 / 9.43.1): the plaintext of MUA Part2 from the login process contains exactly these 95 keys, matching the synthesizer's key set. The package name, version, board, product, ABI, and build id/host/incremental all match the parameter table. In two later 95-key dumps from the same boot, only the timestamps, brightness, x258, x293, and x302/x303 changed. Observed values that were not used to change the synthesizer defaults: x70 was 25 rather than 3 on Wi-Fi; x258 could be 1; x235 was 3348494; and x302/x303 were nonempty and changed within the same process. No sample from a second vendor is available yet.
Let N be the current Unix time in milliseconds, I the saved installation time, and U max(1, uptime_ms)the saved uptime. If uptime is not provided, use N minus the saved boot time. Explicit runtime-state values take precedence over the defaults below; fixed device parameters must remain consistent within the same profile.
Field
Source or default value
x0
Package name "com.xingin.xhs"
x1
Version "9.43.1"
x2
Version code 9431801
x3, x4
I
x5
"JTdCJTdE"
x6
launch_time_ms, default N−1300
x7
hardware
x8
build_time_ms
x9
"{product}-{build_type} {android_release} {build_id} {build_incremental}"
x10
build_fingerprint
x11
build_host
x12
build_id
x13
build_tags
x14
build_type
x15
build_incremental
x16
android_release string, for example "15"
x17
sdk_int, for example 35
x18
security_patch
x19
board
x20
manufacturer
x21
supported_abis, as a comma-separated string
x22
device
x23
brand
x24
model
x25
product
x26
machine
x27
kernel_release
x28
kernel_version
x29
baseband
x30
Resolution and density string, for example "1080,1920,480"
x31
55
x32
1
x33, x34, x35, x36
Battery status, scale, level, and plugged; defaults to 2, 100, 99, and 2
x37
0
x38
network is "wifi"; 1 if so, otherwise 0
x39
usb_config, default "adb"
x40
5
x41, x42
SIM operator name and numeric code, both strings
x43
network, default "wifi"
x44
N
x45
brightness, default 21
x70
3 for Wi-Fi, otherwise 1
x72
Wi-Fi connection time, default N−min(U,145000); forced to −1 for non-Wi-Fi
x73
Wi-Fi DHCP time, default N−min(U,55000); forced to −1 for non-Wi-Fi
x78
Saved UID
x79, x80
PID and TID, which may be overridden by the current runtime state
x87
max(0,N−1027)
x92
sdk_int
x93
3
x98, x120, x131
"0"
x146
28-byte lowercase hex derived from seed, 56 characters
x185
"IiGgSsKkCVvEeP"
x186, x187
−1
x194, x202, x203
"1"
x206, x207
0
x231, x232
total_storage, in bytes
x234
the file_times object; defaults are given below
x235
3275987
x236, x237
free_storage, in bytes
x238
the locale_country string
x242
[]
x243
max(0,N−1063)
x247
the floating-point object below
x258, x259
0
x260
N
x261
1
x263, x264
0
x267
1
x269
the stored value; defaults to 1230768000000
x272
0
x289
N+19
x290
1
x293
max(0,min(free_storage,available_memory)); available_memory defaults to 132146402
x296
""
x301
"LOADED,ABSENT"
x302, x303
""
x304
2
x305
−3
After expanding the merged entries, there are 95 keys in total. Existing slots may be overridden during construction, but adding or omitting keys will deviate from this baseline. The default key order for x234 is "1", "2", "3", with values I−67648, I−70281, and I−70435, respectively. x247 is:
2.5 Seed Derivation and Persistence Strategy
The device seed is used to regenerate the same synthetic identity. It is not an input to native key derivation and does not replace MUA's random s and scalar. A string seed is taken directly as UTF-8; a bytes seed is first converted to lowercase hex text and then encoded as UTF-8; when no seed is provided, a hex string representing 32 random bytes is generated and saved.
Define D(label,L) as the first L bytes of the following byte stream, with the counter j starting at 0:
The j here is the derived-stream counter and is unrelated to MUA.c. Persistent fields are initialized according to the following rules, with saved values taking precedence:
label
derived result
install-age-days
R(label,7,120); I = created_at_ms − that value×86400000
boot-age-minutes
R(label,25,4320); boot_time_ms = created_at_ms − that value×60000
x146
hex(D(label,28))
process-id
R(label,12000,29999)
thread-delta
R(label,1,64); TID = PID + that value
android-uid
R(label,10000,19999)
free-storage-percent
R(label,31,72); free_storage = total_storage×that value using integer division by 100
created_at_ms, installation time, boot time, and x146 are saved with the profile. Keeping only the seed while resetting created_at_ms each time changes the default installation and boot times; x146 itself does not depend on created_at_ms.
The same derivation rule is also used for the registered identity:
label
result or use
android-id
hex(D(label,8)), 16 characters
oaid
hex(D(label,16)), 32 characters
soc-serial
hex(D(label,8)), an optional SoC serial
boot-id
Display D(label,16) directly in UUID format without modifying the version bits
apk-dir, apk-id
Unpadded Base64URL encoding of D(label,16), 22 characters each
sdcard-ctime-sec
R(label,1600000000,1900000000)
sdcard-ctime-ns
R(label,0,999999999)
sdcard-ino
R(label,1,1000000)
sdcard-dev
R(label,1,65535)
sdcard-fsid-lo, sdcard-fsid-hi
Read the little-endian u32 values of D(label,4), respectively
The synthetic installation directory is /data/app/~~{apk-dir}/com.xingin.xhs-{apk-id}. The synthetic filesystem state declares only item 12 successful, with fstype set to 61267; the other 11 items fail both checks. SoC serial and boot_id may both be omitted to replay the empty-file-tree baseline; there are no default fabricated values for MMC, UFS, or additional Settings.
The simulated JNI ID uses the full descriptor java/util/Map->方法签名 for Java String.hashCode: process h=(31*h+unit) mod 2^32 and then feed it into the MD5 described in Section 9.2. The simulated attestation uses SHA256(UTF8(deviceId)), while an empty identity is the SHA-256 of an empty byte string. These substitute values reproduce only the simulated environment; they are not the real formulas used by ART or the TEE.
The synthetic strategy keeps the attestation branch present, sets x148 to an empty string, and sets x128 to the missing-file text; x170 selects the observed branch based on whether the device parameter manufacturer equals Xiaomi . On an actual device, the branch is determined by class loading and collection results, so this selection strategy cannot replace real detection.
3. x-mini-sig: SHA-256 and a GF(2) Affine Transformation
The output is 64 lowercase hexadecimal characters. The last 16 bytes are retained directly from the SHA-256 digest, while the first 16 bytes undergo a fixed GF(2) affine transformation.
3.1 Solving the Affine Relationship
Over GF(2), addition is XOR and multiplication is single-bit AND. Each output bit can be written as a linear combination of 128 input bits, XORed with a constant.
, first solve f(0), then evaluate e_j :
If only request-level inputs and outputs can be obtained, for each pair of digest first halves x and signature first halves y, construct:
Solve this using Gaussian elimination over GF(2). The augmented input matrix must have rank 129 to uniquely determine all coefficients; independent validation is also required beyond the training samples.
This is the method for recovering the affine transformation. The available materials retain the final matrix and independent captured-request vectors, but do not contain enough of the original solving records to reproduce all sampling and elimination steps from that time in this article. Eleven independent GET samples support the matrix's output consistency, but those 11 samples alone cannot recover 128×129 coefficients.
3.2 Bit Order and Evaluation
Write the matrix as 128 rows and 129 columns, with the final column storing the constant term:
Input column 0 corresponds to the least significant bit of the big-endian integer, whereas output row 0 corresponds to the highest bit of the result; the two directions differ. A compact representation of the complete matrix is provided in the appendix.
The intermediate result for one set of inputs is:
POST uses the same formula; the difference lies in the body digest. Comparing signatures is meaningful only when canonical is exactly the same.
Combining canonical, the matrix, and the bit ordering:
rowsthe j column corresponds to input-string bit j (see the column order in Section 3.2), output i bit is written to byte i bit 7 - i % 8.
4. x-mini-s1: Digest Transformation, CBC, and Dual CRCs
S1 does not use SIG's output. It appends counter to canonical and recomputes SHA-256, then adds a timestamp to form the structure to be encrypted, finally outputting standard Base64.
When tracing this chain, the first observation was the 62-byte structure before Base64 encoding. Even with fixed random values, results could still vary across processes: one source of variation was the MUA, which had not yet been fully fixed in canonical, and another was the second-level timestamp written later. Only after fixing the complete canonical, counter, and time can the remaining differences be attributed to the algorithm.
Continuing to trace writes to the intermediate buffers, the result can be split into a 32-byte digest lane, a 44-byte seed, 48 bytes of ciphertext, and two CRCs. The variation that remained after fixing the complete canonical was ultimately traced to the return value oftime(NULL); after fixing this second-level value, the output was consistent across processes.
The lane was initially modeled with a fixed XOR-affine model, but new samples contradicted it. The four transformations were then recovered separately from the VM instructions and memory writes. For CBC, the lookup-table layer was decomposed into the AES S-box and input/output XOR masks, yielding the explicit round function in Section 4.4. These masks are constants recovered from the table, not round keys derived by running the standard AES key schedule on K0.
4.1 SHA-256 and Word Reordering
The byte order within each 4-byte block is not reversed.
counter is encoded as an unsigned 32-bit integer and must match MUA.c. It participates in the nested digest and also appears in the final frame header; changing only the frame header cannot produce a complete S1 for the corresponding new counter.
4.2 Four-Part Lane Transformation
The state is divided into four 8-byte parts, which are processed separately and then concatenated.
Use reflected IEEE CRC-32, with polynomial 0xEDB88320 and initial value 0xFFFFFFFF:
The first part first swaps the high and low nibbles of each byte, then uses the low 8 bits of the internal CRC as a uniform XOR mask:
The second part:
The third part uses a nibble S-box (indices 0..15):
The CRC input is the expanded 16 bytes; the final XOR is still applied to the original 8 bytes.
The fourth part:
The four outputs are concatenated into lane32. The CRC recurrence for the first part was compared byte by byte with the native intermediate state; the other parts use independent transformations, so the first-part formula cannot be applied to all 32 bytes.
4.3 Seed and Frame Structure
Offset range
Length
Contents
[0,1)
1
marker 00
[1,5)
4
counter, little-endian
[5,6)
1
version 01
[6,54)
48
CBC ciphertext
[54,58)
4
seed CRC, big-endian
[58,62)
4
source CRC, big-endian
The second CRC covers 53 bytes and excludes the outermost marker and counter. If it is also appended to the temporary source buffer, that buffer is 57 bytes long; this is distinct from the CRC input length.
The 62 bytes become 84 characters in standard Base64, with one padding character at the end=.
timestamp_seconds is the Unix time in seconds, written as a u32. It is different from the uptime in milliseconds in Part1.t.t and does not participate in nested SHA-256; changes in time appear in the seed, CBC ciphertext, and subsequent verification layers.
4.4 Fused AES-like CBC
CBC IV:
First XOR the current plaintext block with previous, whose initial value is IV. The block transform uses the standard AES S-box and MixColumns, but the operation order and round-key table are organized as follows:
Transpose converts the 16-byte column-major input into a row-major state; ShiftRows rotates row r left by r positions. Round-key bytes correspond one-to-one to the row-major state above, so the key must not be transposed again.
The nine round keys, in order, are:
K0 corresponds to ASCII xhs0oh2iu4an7og2. The final layer contains only one S-box and no MixColumns.
The AES S-box is constructed by first finding the multiplicative inverse in GF(2^8), followed by an affine transformation. The irreducible polynomial is 0x11B, with 0 treated as its own inverse:
MixColumns computes the following for each column [a,b,c,d]:
Multiplication is performed in the same GF(2^8). Padding the 44-byte seed with four 0x04 makes exactly three blocks. The transformation above cannot be replaced directly with standard AES-CBC using an initial key.
There are currently 23 fixed-time native full-frame comparisons with counter=1, and 5 full-frame comparisons with counter=2 at fixed times. The latter cover five distinct Unix-second values, including the same second as the counter=1 corpus, an adjacent second, a one-hour offset, and 1 and 1700000000. These five raw62 values match the formula byte for byte with the same canonical, counter=2, and corresponding timestamp. 51244a19 and 170e remain unchanged in these comparisons.
A separate arm64 libtiny from Android 9.47.0 (build 9470809) had contents identical byte for byte to the 9.43.1 sample. Therefore, the nine round keys, FA, C, 51244a19 and 170e did not change between these two application versions. These constants do not appear as contiguous plaintext in the SO; the determination of drift is based on the complete library images, not on literal-value searches.
To turn the formulas above into an executable check, the following is a minimal implementation with no third-party dependencies;SBOX is generated directly by finding the multiplicative inverse in GF(2^8), matching the standard AES S-box:
5. main_hmac: Recovering key64 from Shield
main_hmac is encrypted material returned by the server, not the HMAC result for the current request. The client obtains a Base64 string from/api/sns/v1/system_service/vfc_codethe response headerxy-ter-str.
Fixed header:
The decrypted result must be 96 bytes, with the first 16 bytes equal to header. Shield uses the middle 64 bytes; the footer does not participate in the request HMAC. No PKCS#7 unpadding is performed here.
Ninety-six bytes are exactly six AES blocks, but the length alone cannot prove that this is AES. Other evidence includes the standard S-box, row shifts, and column mixing in the block transform, along with a deviceId-driven round-key expansion. The difference from standard AES is confined to the Rcon values below, not to a different custom S-box.
5.1 Custom AES Key Expansion
deviceId uses a 36-character hyphenated UUID in ASCII:
It then performs AES-128 key expansion, replacing Rcon with the following 10 big-endian u32 values:
RotWord rotates four bytes left by one byte, and SubWord applies the standard AES S-box to each byte. This produces 44 words, or 176 bytes of round keys.
During CBC decryption, P_i = D_round_keys(C_i) XOR C_(i-1)the predecessor of the first block is 16 zero bytes. Plaintext slices [0:16],[16:80], and [80:96] yield the header, key64, and footer, respectively.
The block-cipher round function is standard AES: initial AddRoundKey, nine rounds of SubBytes, ShiftRows, MixColumns, and AddRoundKey, followed by a final round that omits MixColumns. What changes is the key schedule; calling a standard AES library with only key16 produces a different result.
Example:
To establish that this recovery is correct, check the plaintext header and key64 across deviceIds. The decrypted results for 18 sets of corresponding materials are consistent; an encrypt/decrypt round trip alone cannot replace this comparison.
There is no local formula that can derive key64 offline from deviceId alone. libxyass writes xy-ter-str to prefs s/main_hmac only after receiving it, then uses deviceId as the AES wrapping key to decrypt the 64-byte value; there is no f_key literal in the .so. A cold start with empty prefs retains the 70-byte Shield form. Under the normal protocol, key64 can be recovered once main_hmac has been obtained.
5.2 Reverse Encryption
Given the same deviceId, known key64, and footer, main_hmac can be reconstructed in reverse:
E uses the custom round keys from Section 5.1. The plaintext consists of exactly six blocks, with no additional padding. The footer must be retained as input; it cannot be omitted simply because Shield does not use it. Reverse encryption only demonstrates that the wrapper can be reconstructed; it does not mean that an unknown key64 can be generated from deviceId or that new material matching the server-issued material can be produced independently.
key64 can likewise be extracted using only the standard library.SBOX is the same as the example in Section 4.4; decryption uses the standard AES block function with the round keys from this section:
6. Shield: Request HMAC and SSK Extension
The outer format is:
Standard Base64 is used, with padding preserved. raw has two possible lengths: 70 bytes for the basic structure, and 118 bytes when both main_hmac and main_ssk are present.
6.1 HMAC Input
Concatenate the components in the following order, with no line breaks, question marks, or extra separators:
Missing headers are treated as empty strings, and the result is then encoded as UTF-8 bytes. host, scheme, and METHOD are not part of this input. The platform segment is rebuilt from version constants and deviceId rather than read directly from the potentially differentxy-platform-info value.
body must match the content actually sent. For form requests, sign the already percent-encoded form text; for JSON, sign the final serialized bytes rather than regenerating a semantically equivalent JSON object.
6.2 Custom HMAC-MD5
The outer HMAC structure is unchanged:
key64 is already exactly 64 bytes; XOR 0x36the inner and outer pads XOR 0x5care formed by XORing each byte with the same constant. inner and outer are both 16-byte raw digests; when computing outer, do not convert inner to hex text.
The native initialization and 64-round compression still retain the MD5 structure, so the initial state, rotation table, additive constants, message indices, and register updates can be compared item by item. The modifications listed below collectively form MD5_mod.
The initial state is the reverse of standard MD5:
T table:
First copy T to K, then modify:
The rotation counts initially use the four standard MD5 groups, each repeated four times:
Then overwrite 10 positions:
Round functions and message indices:
Starting at round 39, reorder the input registers according to PIN:
Split each 64-byte block into 16 little-endian u32 values, denoted M:
Padding follows the MD5 rules: append 0x80, pad with zeros until the length is congruent to 56 modulo 64, then append the original bit length as a little-endian u64. After all blocks have been processed, output the state as four little-endian u32 values.
For this kind of variant, the initial state, table constants, round functions, message indices, and register-update order must each be confirmed. Replacing only MD5's T table is insufficient; the 32-bit truncation before rotation must not be omitted either.
One digest check value:
6.3 70-byte base structure
The constants are as follows:
RC4_KEY is 72 bytes of UTF-8. Use the standard RC4 KSA and PRGA:
hf selects EMPTY_HF or NONEMPTY_HF based on whether main_hmac exists; it is not a truncated HMAC value. The base structure is 16 + 1 + 36 + 1 + 16 = 70 bytes long.
When main_hmac is absent, the request HMAC is not computed, so raw70 depends only on deviceId; changing path, query, or body does not change it. Only after main_hmac is obtained does tail bind the request contents to Shield. 70B and 118B are encoded as 96 and 160 Base64 characters, respectively; with XY added, their total lengths are 98 and 162 characters.
6.4 48-byte SSK extension
Comparing the outputs with and without SSK shows that the base portion remains 70 bytes, with an additional 48-byte tail. Further splitting the data before serialization reveals two TLVs: a 20-byte proof with tag 4 and a 24-byte proof with tag 5.
native uses the standard Base64 ASCII representation of main_ssk, rather than using its 32-byte raw value directly:
The length bytes 14, 18 are hexadecimal and represent 20 and 24, respectively.
The extension uses the specified range of the RC4 keystream:
The offset is 68 rather than 70 because the first two bytes of the final raw value are inserted during post-processing. Keep the equivalent construction of the base structure separate from the stream offset of the extension; do not concatenate plain54 and plain48 and run RC4 over the result again.
When main_hmac is empty, the extension branch is not entered even if SSK is present. Changing query changes request_hmac and then req_proof; changing SSK changes ssk_proof first.
The nine fixed-nonce samples covering three SSKs and three URLs all reproduce the complete 118 bytes. The 4-byte nonce comes from the generator in the libxyass extension branch; it is not getrandom or /dev/urandom. The function increments a process-local atomic counter, then XORs it with the low 32 bits of the address of the sp+0x20 slot on the current stack, writing the result in little-endian order:
It then uses SHA1(ASCII(main_ssk_b64) || nonce4) to fill tag 5. sp+0x20 is part of the runtime stack layout, not a protocol constant; because stack addresses are stable in the emulator, the low bits of the nonce change with the counter across consecutive requests and flip via XOR when the counter crosses a power of 16, rather than increasing monotonically. ASLR on physical devices makes this a different 4-byte value for each process. A portable implementation that does not know the stack address should still treat the nonce as a per-request input rather than hard-coding a cross-process constant table. Thirty-two consecutive native outputs from the same process satisfy the formula above.
The 70B form without main_hmac can be reproduced independently without a custom MD5:
7. SSK: X25519 and AES-256-GCM
The client sends an ephemeral X25519 public key to /api/sns/v1/user/activate under the field name client_public_key_base64. The public key is first Base64-encoded, then percent-encoded according to form rules.
The SSK server public key is:
The decoding layer can also accept URL-safe Base64. The shared secret is used directly as a 256-bit AES key, with no HKDF in between; an all-zero shared secret must be rejected, the GCM tag must be verified, and the plaintext must be 32 bytes. The SSK GCM nonce is 12 bytes and is unrelated to nonce4 in the Shield extension.
This process must not be mixed up with the MUA key/IV split: MUA splits the shared secret into an AES-128-CBC key and IV, whereas SSK uses the complete shared secret for AES-256-GCM and reads the IV from the response.
The request and response in this exchange must use the same ephemeral key pair: send the public key with the request and retain the corresponding private key while processing the response. Generating a new private key and then decrypting the original response changes the shared secret and causes GCM tag verification to fail. The MUA scalar and activate's ephemeral private key should likewise be managed as separate session materials.
RFC 7748 vectors can validate the standard X25519 primitive, and a constructed GCM message can validate decryption and tag-rejection behavior, but neither alone proves that a particular real activate response used the same materials.
Deriving main_ssk:
shared using the x25519(), with this section's SSK server public key as the peer rather than MUA's MUA_SERVER_PUBLIC_KEY. The resulting 32 bytes are used directly as the AES-256-GCM key; do not reuse slotA from Section 2.1.
8. Registration request: GID and encrypted snapshot
The registration endpoint is POST /api/v1/register/android. The request carries MUA, SIG, and S1, but not Shield. The body is compact JSON generated with the following key order:
a, c, k, p, s, and v are consistent with those in MUA Part1 from the same session. e is the plaintext identity object, which in the empty-identity sample from the first launch is:
The encoding process for d is:
d shares slotA and the outer algorithm with MUA Part2 from the same session, but its plaintext is a different data structure. MUA Part2's string cannot be used directly as d.
In one set of registration samples, the snapshot plaintext was 960 bytes, the zlib output was 448 bytes, and the padded ciphertext was 464 bytes. The length is determined by the compressed content; it is not a fixed protocol value.
The dependency order for the registration signature is:
Once signing is complete, the body is not reordered or modified. The server response's data.g was 56 hex characters in the observed samples, and was subsequently written unchanged to x-mini-gid and gid in the common parameters.
The field d and body assembly:
Here, key and iv share slotA with MUA Part2 from the same registration session; after assembling the body, calculate SIG and S1 in the order specified in section 10.
9. Hash and encoding fields in snapshot
The snapshot fields are subject to conditional branches, so the 24 keys are only a baseline. For example, with other conditions unchanged, x252 and x253 are added starting with SDK 28; optional collection results can add or remove further keys. The following discusses only the hash and encoding relationships that affect reproduction.
9.1 x86: XXH3-128 and CRC32
XXH3 uses the standard seeded 128-bit algorithm, with the result formatted as high64 << 64 | low64. CRC32 uses the standard reflected IEEE form and always outputs 8 hex characters.
The input is formed by concatenating successfully collected values in order, with no separators:
SoC/MMC/UFS and the Settings values above are normalized by removing ASCII whitespace from both ends and then taking the first 200 bytes. cpuinfo and boot_id are not part of this input chain.
Fields of this kind are best confirmed by single-variable differentials: hold all other collection items constant, change one input, and observe the concatenation buffer and final hash. Evidence for a fixed seed should also include immediate-value loads in native, rather than relying only on fitting the outputs. The short-, medium-, and long-input branches of XXH3 should use a standard implementation; two 64-bit hashes should not simply be concatenated as a substitute for 128-bit output.
x86The combination of has only one line,xxh3_128The complete implementation of is given in Appendix B:
9.2 x137: MD5 of JNI method IDs
Encode the following seven JNI method IDs in order as little-endian u32 values, concatenate them into 28 bytes, compute standard MD5, and output uppercase hex:
The hash formula and the source of the IDs must be considered separately.
The real-device path is established. Native calls java/util/Map with FindClass, then calls GetMethodID, using str x0 to write the 64-bit jmethodID into the global table. MD5 then reads only the first 4 bytes of each slot, namely the low 32 bits in little-endian order. All pointers in the device sample are below 4 GB, so their high 32 bits are zero and trunc32 equals the full pointer; this must not be generalized to every 64-bit address layout.
These IDs are addresses of ART method objects, located in an approximately 2.8 MiB, fileless rw- mapping. The seven values remain unchanged across process restarts during the same boot; after a device reboot, the mapping base is randomized and all seven values shift together while their relative offsets remain unchanged. Thus x137 is a runtime variable for the duration of a boot, and the server cannot strictly validate it against a fixed table. The simulation environment can still use Java hashCode of the descriptors as a substitute for the IDs, but only as a fallback oracle, not as ART's allocation formula.
x137:
9.3 attestation and environment branches
x147 decodes the attestation bytes as UTF-8, replacing invalid sequences with U+FFFD. x86 uses the raw bytes; it must not use x147 re-encoded after decoding.
The input source is established. Native uses Class.forName to load com.huawei.attestation.HwAttestationManager, then reflectively invokes getDeviceID(I)[B. This class is not in the APK; it is a Huawei system class. When the class exists, x148 is an empty string and the raw bytes enter x86; if class loading fails, neither x147 nor x148 appears, and the attestation bytes do not enter x86 either. After the failure, it also probes attestation_service and IHwTelephony$Stub, but they cannot substitute for the return value of getDeviceID.
On the Xiaomi MI 6 (Lineage, with no Huawei package), all three process restarts took the omission branch: the x147 path in the registration snapshot produced no bytes. In the same process, AMediaDrm_getPropertyByteArray("deviceUniqueId") consistently returned 32 bytes, but that is a Widevine collection; when HwAttestationManager was hidden, x147 still disappeared, so it is not an input to x147. The Huawei TEE's getDeviceID payload remains device-specific and uncovered.
The simulation environment still defaults to SHA256(UTF8(deviceId)) as the fallback; raw bytes may also be injected, or the omission branch selected explicitly.
Likewise, the OAID provider, file readability, and JNI environment can change the snapshot. Reproducing serialization and encryption for one fixed environment does not mean that the collection behavior of every device manufacturer has been reproduced.
9.4 x165: String encoding of filesystem information
x165 encodes the file status and filesystem status of 12 fixed targets into an object. Successful items use one-based decimal index strings as keys,/sdcard corresponding to "12". The format of each item is:
The fixed indices and targets are as follows; these are Android collection targets, not directories on the reader's computer:
Index
Collection target
1
/data/system
2
/data/data/
3
/data/data/com.android.shell
4
/data/system/install_sessions
5
/data/data/com.google.android.webview
6
/data/data/com.google.android.gms
7
/dev/__properties__/u:object_r:radio_prop:s0
8
/dev/__properties__/u:object_r:ffs_prop:s0
9
/dev/__properties__/u:object_r:debuggerd_prop:s0
10
/dev/fd/1
11
/dev/fd/0
12
/sdcard
On this MI 6, the 12 targets include /data/system, /data/data/, /data/data/com.android.shell, /data/system/install_sessions, /sdcard, /dev/fd/0, /dev/fd/1 and the two radio/debuggerd property nodes are present; webview, the GMS data directory, and ffs_prop. /system/bin/getprop and /system/bin/sh are both present; under the helper rule in Section 9.5, this device's x128 should take the omission-key branch. This has not yet been checked directly against a registration snapshot taken after clearing the data.
There is no separator between the seconds and nanoseconds of ctime; nanoseconds are at least 9 digits and are zero-padded as needed. Both are output as signed 64-bit integers. inode, device, and fstype are output as unsigned 64-bit integers. fsid encodes each of two u32 values as 8 lowercase hex characters and concatenates them in low, high order.
When stat fails but statfs succeeds, ctime, inode, and device are set to zero, while fsid and fstype are retained; when stat succeeds but statfs fails, fsid is the literal *, and fstype is 0. If both fail, the item is omitted. If all targets fail, x165 is null rather than an empty object.
For example, if only item 12 is retained, all stat values are zero, and the two fsid words are 0xd3609fe8, 0x04970d6b, while fstype is the decimal value 61267, the result is:
9.5 x170 and x128 conditional branches
In the observed class-loading experiments, when com.android.id.impl.IdProviderImpl is present, x170 is "id_provider", OAID is also written to x173, x174, x175, and x181, and appended once in x86. After the class is hidden, x170 falls back to "vivo", the four copies disappear, and x86 no longer appends OAID; x171 still retains OAID. This is the branch result in the simulated environment and cannot be used to infer the provider on a real device from the manufacturer string alone.
x128 corresponds to the errno=2 text after a file-open failure, "No such file or directory". In the observed experiments, this key disappeared when both getprop and sh system helpers were available; it remained when only getprop was available. boot_id follows a separate path through x47; in the samples, its trailing newline was removed and the first 35 characters were retained, and it does not participate in x86. The native code's complete handling of abnormal lengths and encodings has not yet been covered.
9.6 Snapshot fields and presence conditions
The following fields have been mapped. Strings are serialized as UTF-8, and capacity fields are byte-count integers; “present” means that the collection branch succeeded or that the caller explicitly supplied the input.
Field
Value or source
Presence condition
x137
MD5 of the seven Map method IDs (trunc32), uppercase hex
Baseline
x165
Filesystem object or null
Baseline
x170
OAID provider label
Baseline
x171
OAID string
Baseline
x20
manufacturer
Baseline
x21
CPU ABI list string
Baseline
x25
product
Baseline
x46
android_id
Baseline
x49
ApplicationInfo.sourceDir
Baseline
x77
boot hardware
Baseline
x86
XXH3-128 and CRC32 concatenated into a 40-character string
Baseline
x231, x232
Total storage in bytes
Baseline
x236, x237
Free storage in bytes
Baseline
x128
Missing-file error text
Failure branch in Section 9.5
x147
UTF-8 replacement-decoded result of the raw attestation bytes
Attestation branch present
x148
Empty string in observed samples
Attestation branch present
x173, x174, x175, x181
Four copies of the OAID
IdProviderImpl branch
x252, x253
ApplicationInfo.publicSourceDir
SDK≥28
x149
Normalized SoC serial
Present and nonempty after normalization
x151
Normalized MMC cid
Present and nonempty after normalization
x153
Normalized UFS id
Present and nonempty after normalization
x47
First 35 characters of boot_id after trailing CR/LF removal
Input present
x110
Kernel UUID truncated in the same way as boot_id
Input present
x76
Integer 0 in the cpuinfo differential sample
Corresponding collection sample present
SoC serial, MMC cid, and UFS id correspond respectively to /sys/devices/soc0/serial_number, /sys/block/mmcblk0/device/cid, and /sys/ufs/ufsid; their normalized bytes are included in x86, while the field values decode those bytes as UTF-8 with replacement. The two representations cannot be interchanged for invalid UTF-8 input. When publicSourceDir is not provided separately, the current reconstruction strategy uses sourceDir.
Settings name
field
whether normalized bytes enter x86
gcbooster_uuid
x133
yes, after android_id
uuid
x158
no
pps_oaid
x169
yes, after gcbooster_uuid
bluetooth_address
x63
no
key_mqs_uuid
x132
no
ad_aaid
x134
no
mi_health_id
x157
no
persist.sys.oppo.opmuuid
x159
no
com.vivo.pushservice.client_id
x160
no
iRoamingKey
x161
no
com.vivo.pushservice.back_up
x162
no
ZHVzY2Lk
x163
no
This table covers the branches mapped so far; it does not treat other native branches that have not yet been collected successfully as nonexistent. The storage and hardware slots with the same names in MUA Part 2 come from a different profile object, so the two JSON objects must not be merged on that basis.
10. Combining the layers into a single request
After session bootstrapping is complete, a request is processed in the following order:
Determine the final method, path, query, and body.
There are three kinds of state here that are easy to confuse: the session randomness used by MUA, the counter for each request, and Shield's nonce4. They have different lifecycles and cannot substitute for one another.
The same request object should provide the bytes needed for both canonical and the Shield source. If the HTTP library reorders the query, changes percent-encoding, or reserializes the body after signing, the result will be that the local formula is correct but the content actually sent is different. Registration in particular requires generating the final body before computing SIG and S1.
10.1 A Reproducible Set of SIG / S1 Inputs
The following is a synthetic vector for checking the formulas. MUA uses the literal part1.part2., to isolate SIG/S1; it does not represent a real MUA that can be submitted to the server.
Construct canonical according to the LF concatenation rule described in this article. The result should be:
This vector was cross-checked against two implementations and is intended to verify bit order, counter, time units, and encoding. A native full-frame comparison with counter=2 at a fixed time was performed separately; see Section 4.4.
The existing fixed-input comparisons support the MUA encoding, SIG, S1, main_hmac decryption, and Shield formulas described here. Beyond formula-level comparisons, a pure-Python client completed end-to-end validation in an anonymous session: it obtained a GID during registration, recovered main_hmac and main_ssk, and received code=0 from homefeed, imagefeed, search/notes, user/info, and user/posted across several synthetic devices and sessions. This validates that the server accepts the constructed headers and the associated state transitions. The real-device input for x137 has been identified as a boot-lifetime ART address (see Section 9.2); areas not yet covered include the real TEE attestation bytes and the complete OEM collection branches. Those gaps limit the scope of the conclusions and cannot be filled by a single successful server response.
Appendix: SIG Affine Matrix
Each row contains 129 bits placed in the upper 129 bits of 17 bytes in the order “column 0 through column 128”; the lower 7 bits are zero-filled. The hex below totals 2176 bytes and 128 rows; for formatting, each text line contains four matrix rows.
Decoding method:
This bit-packing order is used only to store the matrix. After decoding to rows, calculate using the input-column bit order from section 3.2; do not mistake the most-significant-bit-first packing order for the order in which bits are taken from the digest.
Appendix B: Complete XXH3-128 Branches
On x86, this uses the seeded XXH3-128 from xxHash 0.8.3. What is expanded here is the standard hash implementation; what is specific to this protocol is the seed 0x48FA5412, the input concatenation order, and the rule for appending CRC32 at the end.
The implementation normalizes the seed to an unsigned 64-bit value. Integers are read in little-endian order, and 64×64 multiplication retains the full 128-bit result; when folding is required, the high and low 64-bit halves of the product are XORed. Truncation after addition, multiplication, and negation follows the MASK in the code.
Input length
Processing method
0
XOR the two 64-bit values from seed and secret, then apply XXH64 avalanche to each
1–3
Combine the first, middle, and last bytes with the length to construct two values, then apply avalanche
4–8
Combine the leading and trailing u32 values, mix in seed and secret, then perform 128-bit multiplication and two final mixing steps
9–16
Mix the leading and trailing u64 values with two bitflips, perform two multiplications, and obtain high/low
17–128
Take 16-byte pairs from both ends of the input and accumulate two state values with mix32
129–240
Process the first 128 bytes and apply avalanche, then process the remaining full groups and the overlapping tail window
≥241
Use 8 accumulators to process 64-byte stripes, scramble the state every 1024 bytes, and finally merge high/low separately
The medium-length mix16 uses (secret_low + seed) and (secret_high - seed) to mask the two input u64 values, then folds the product into 64 bits. mix32 updates low/high simultaneously and cross-XORs the sum of the other half's two words. The final window in the 129–240-byte branch uses the negated seed; its position and direction cannot be swapped with those of the preceding full group.
For long inputs with a nonzero seed, the implementation first derives a 192-byte secret: the first u64 of each 16-byte unit is increased by the seed, and the second u64 is decreased by the seed. Each stripe is split into eight lanes; the original values are added to adjacent accumulators, while the two u32 halves of the masked values are multiplied and added to the current accumulator. After each complete 1024-byte block, the state is scrambled. The final 64-byte window is processed separately and may overlap the preceding window. In the end, high and low are merged using different secret offsets, so this is not two independent 64-bit hashes.
The code below runs independently using only Python's built-in features. It is a Python adaptation derived from xxHash 0.8.3; the original algorithm is copyright Yann Collet and remains available under the BSD-2-Clause license. The complete license text appears at the end.
The return value is high64 << 64 | low64. When generating x86, format this integer as fixed-width 32-bit uppercase hex, then append the fixed-width 8-character uppercase CRC32 of the same input:
License: