Original English rewrite with full credit. This article is an independent English-language rewrite of “Attacking Samsung RKP” by Alexandre Adamski, published on the Impalabs Blog on November 25, 2021.
All vulnerability research, reverse engineering, source-code excerpts, ASCII diagrams, and the exploit proof-of-concept are the work of the original author and were originally disclosed by Longterm Security. The prose below is rewritten in our own words and trimmed for blog length; the code, diff and ASCII fragments reproduced here are short excerpts kept verbatim for technical accuracy. For full code listings, reverse-engineered functions, and the complete walk-through, read the source.
Source: blog.impalabs.com/2111_attacking-samsung-rkp.html · prior post: Samsung RKP Compendium

Executive Summary
Samsung’s Real-time Kernel Protection (RKP) is a security hypervisor that lives at EL2 on Exynos-based Galaxy phones and guards the Android kernel at EL1: it polices stage 2 page tables so the kernel cannot mark its own code writable, modify its credentials structures, or hand itself new executable pages without going through RKP’s sanctioned interfaces. The 2021 Impalabs/Longterm research described three independent bugs in that policing layer — serious enough that a kernel-level attacker could (1) remap hypervisor memory itself as writable, (2) get an arbitrary kernel page marked executable via the “dynamic load” path, and (3) flip RKP-protected read-only kernel pages back to writable.
The detailed value of the original write-up is in the reverse engineering: it traces every relevant hypercall (RKP_CMD_NEW_PGD, RKP_CMD_FREE_PGD, RKP_CMD_WRITE_PGT3, RKP_DYNAMIC_LOAD), reconstructs RKP’s internal bookkeeping structures (memlist, sparsemap, physmap, ro_bitmap, dbl_bitmap, protected_ranges, executable_regions, page_allocator), and then shows precisely which check is missing in each vulnerable path. It is also rare in another way — the author binary-diffed two consecutive Samsung patches and demonstrated that the first one was incomplete, finding a new exploit path that survived June 2021 and was only properly closed in October 2021. The CVE IDs are CVE-2021-25415, CVE-2021-25416 and CVE-2021-25417.
Background: RKP at EL2
Virtualization extensions in two sentences
On 64-bit ARM, address translation can take two stages. The kernel at EL1 owns stage 1 (virtual → intermediate physical) and the hypervisor at EL2 owns stage 2 (intermediate physical → real physical). Stage 2 is what RKP uses to enforce kernel integrity: even if the EL1 kernel rewrites its own page tables, the EL2 stage 2 mapping decides whether the resulting physical access is allowed.
What Samsung RKP claims to enforce
RKP’s job is to make the kernel honest. In particular: kernel code (.text) and read-only data (.rodata) cannot be modified once locked; kernel page tables, cred structures, task_security_struct, vfsmount, and the various PGDs (swapper_pg_dir, tramp_pg_dir, empty_zero_page) live in stage 2 read-only regions; new executable kernel pages can only appear through a tightly controlled “dynamic load” interface and only for signed binaries. The hypervisor itself sits in physically separate memory regions that the kernel must never reach — tracked by the protected_ranges memlist.
Research device
The author worked on a Samsung Galaxy A51 (model SM-A515F), firmware A515FXXU4CTJ1. It runs an Exynos 9610 SoC. The same RKP code lineage covered Exynos 9610 / 9810 / 9820 / 9830 (Galaxy S10/S20 families) on Android Q (10) and R (11), with the Galaxy S20 introducing the newer H-Arx hypervisor that is out of scope here. Critically, JOPP and ROPP — the jump/return-oriented programming mitigations — are not present on the A51, which simplifies the post-RKP-bypass step but is not what the bugs themselves depend on.
CVE-2021-25415 — Remapping RKP memory as writable from EL1
The two missing checks
The hypervisor exposes two routines that change stage 2 permissions on a single page or on a range:
rkp_s2_page_change_permission()— per-page; performs some validation againstphysmapbefore flipping the descriptor.rkp_s2_range_change_permission()— per-range; does not apply the same validation.
Compounding the gap, when stage 2 mappings are torn down through s2_unmap(), the affected pages are never marked as S2UNMAP in RKP’s physmap. So later code that decides “is this page safe to operate on?” based on its physmap tag cannot tell that the page once belonged to the hypervisor.
Building a fake PGD that flips the type tag
Putting those two flaws together, an attacker who can already read and write kernel memory and issue HVCs can construct a tiny fake page-table tree whose only purpose is to convince RKP to relabel a hypervisor page in physmap:
- Allocate two 4 KB kernel pages and zero them. One acts as a fake PGD, the other as a fake PMD.
- Inside the fake PGD, place a single table descriptor whose physical target is the page you actually want to corrupt — specifically the backing store of RKP’s own
protected_rangesmemlist at0x870473D8. - Call
RKP_CMD_NEW_PGDon the fake PGD. RKP walks it, follows the table descriptor, and tags the “child” (the hypervisor page) as an L2 page table inphysmap— which forces it read-only in stage 2. - Call
RKP_CMD_FREE_PGDon the same fake PGD. RKP walks it again, sees the child is no longer needed, and marks itFREE— which makes it writable in stage 2. - The hypervisor page is now writable from EL1. Zero its size field to disable the
protected_rangescheck, then directly rewrite the stage 2 L2 descriptor that covers the rest of the hypervisor (0x8702A1C0on the test device) to a fully writable block descriptor.
The fake-PGD / fake-PMD relationship is sketched verbatim in the original article as:
| +--------------------+ | +--------------------+ .-> +--------------------+
| | | | | | | |
+--------------------+ | +--------------------+ | +--------------------+
| table descriptor ---' | | table descriptor ---' | |
+--------------------+ | +--------------------+ +--------------------+
| | | | | | |
+--------------------+ | +--------------------+ +--------------------+
| read PMD | read PMD
in kernel memory | in kernel memory
"fake PT"
in hypervisor memory
The address of protected_ranges on the test device was recovered by tracing static heap allocations and is reproduced verbatim from the source:
>>> f = lambda x: (x + 0x18 + 7) & 0xFFFFFFF8
>>> 0x87046000 + f(0x8A) + f(0x230) + f(0x1000) + f(0xA0) + 0x18
0x870473D8 # protected_ranges memlist address
The stage 2 L2 descriptor for the hypervisor text on the test device is at:
>>> 0x8702A000 + ((0x87000000 - 0x80000000) // 0x200000) * 8
0x8702A1C0
And the writable block descriptor that overwrites it is 0x870004FD, decoded as in the original write-up:
0 1 00 11 1111 01 = 0x4FD ^ ^ ^ ^ ^ ^ | | | | | `-- Type: block descriptor | | | | `------- MemAttr[3:0]: NM, OWBC, IWBC | | | `---------- S2AP[1:0]: read/write | | `------------- SH[1:0]: NS | `--------------- AF: 1 `----------------- FnXS: 0
Proof of concept
The first lines of the author’s PoC define the relevant hypercall numbers and the precomputed device-specific addresses used above:
#define UH_APP_RKP 0xC300C002
#define RKP_CMD_NEW_PGD 0x0A
#define RKP_CMD_FREE_PGD 0x09
#define RKP_CMD_WRITE_PGT3 0x05
#define PROTECTED_RANGES_BITMAP 0x870473D8
#define BLOCK_DESC_ADDR 0x8702A1C0
#define BLOCK_DESC_DATA 0x870004FD
uint64_t pa_to_va(uint64_t va) {
return pa - 0x80000000UL + 0xFFFFFFC000000000UL;
}
void exploit() {
/* allocate and clear our "fake PGD" */
uint64_t pgd = kernel_alloc(0x1000);
for (uint64_t i = 0; i < 0x1000; i += 8)
kernel_write(pgd + i, 0UL);
The remainder of the PoC populates the fake PGD with the target descriptor, issues the two hypercalls described above, patches the protected_ranges size field, and overwrites BLOCK_DESC_ADDR with BLOCK_DESC_DATA. Full code is in the original post.
The first patch and its hole
The June 2021 patch (binary diffed against firmware G973FXXSBFUF3) added a type argument to rkp_s2_page_change_permission() and inserted check_kernel_input() calls into rkp_l1pgt_process_table(), rkp_l2pgt_process_table(), and friends. Conceptually the diff looked like this:
int64_t rkp_s2_page_change_permission(void* p_addr,
uint64_t access,
+ uint32_t type,
uint32_t exec,
uint32_t allow) {
// ...
if (!allow && !rkp_inited) {
// ...
- return -1;
+ return rkp_phys_map_set(p_addr, type) ? -1 : 0;
}
It was insufficient on two counts: (a) s2_unmap() still did not write S2UNMAP into physmap, and (b) the broader rkp_s2_range_change_permission() never gained the same validation. The author then found a new way in through the “dynamic load” pipeline, by chaining dynamic_load_ins → rkp_set_range_to_rox → set_range_to_rox_l3 and dynamic_load_rm → set_range_to_pxn_l3, both of which sit on top of the still-undefended range path. Schematically:
rkp_cmd_dynamic_load
├─ dynamic_load_ins
│ └─ dynamic_load_make_rox
│ └─ rkp_set_range_to_rox (calls set_range_to_rox_l3)
│ └─ rkp_s2_page_change_permission(table, 0x80, ...)
└─ dynamic_load_rm
└─ dynamic_load_set_pxn
└─ rkp_set_range_to_pxn (calls set_range_to_pxn_l3)
└─ rkp_s2_page_change_permission(table, 0, ...)
The second patch (October 2021)
The October patch (binary diffed against G973FXXSEFUJ2) finally:
- Added a
check_kernel_input()on the physical address insiderkp_s2_page_change_permission(). - Made
rkp_s2_range_change_permission()callprotected_ranges_overlaps()on the full range to reject anything that touches the hypervisor. - Added a per-page loop in the range path that verifies none of the candidate pages are tagged
S2UNMAPinphysmap, callingrkp_policy_violation()if they are. The conceptual diff is:
protected_ranges_overlaps(start_addr, end_addr - start_addr);
do {
rkp_phys_map_lock(addr);
if (is_phys_map_s2unmap(addr))
rkp_policy_violation("RKP_1b62896c %p", addr);
rkp_phys_map_unlock(addr);
addr += 0x1000;
} while (addr < end_addr);
CVE-2021-25416 — Executable kernel pages via the “dynamic load” interface
Samsung exposes a controlled way for the kernel to ask RKP to mark a buffer executable: the RKP_DYNAMIC_LOAD command, used by the FIMC-IS camera subsystem to load signed firmware blobs. The hypervisor receives a structure that describes one or two code segments inside a larger binary:
typedef struct dynamic_load_struct{
u32 type;
u64 binary_base;
u64 binary_size;
u64 code_base1;
u64 code_size1;
u64 code_base2;
u64 code_size2;
} rkp_dynamic_load_t;
Three subtypes exist: RKP_DYN_FIMC (one code segment), RKP_DYN_FIMC_COMBINED (two code segments), and RKP_DYN_MODULE (kernel modules — not actually loadable at runtime). The kernel-side caller is fimc_is_load_ddk_bin(); the hypervisor-side handler is dynamic_load_ins().
The missing containment check
The validation routine dynamic_load_check() does ensure that the binary itself does not overlap previously-loaded executables, and that none of the binary’s pages are in ro_bitmap. But it never verifies that the two code segments are inside [binary_base, binary_base + binary_size). The hypervisor takes code_base1/code_size1 (and the same for segment 2) at face value, and calls dynamic_load_protection() → rkp_s2_range_change_permission() to mark them read-only in stage 2, then dynamic_load_make_rox() → rkp_set_range_to_rox() to mark them read-only-executable in stage 1.
So an attacker who passes a legitimate-looking signed binary but with code_base1 pointing at an arbitrary kernel page gets that arbitrary kernel page promoted to RX. dynamic_load_verify_signing() checks the bytes inside [binary_base, binary_base + binary_size), not the code segments — and on engineering firmwares a NO_FIMC_VERIFY flag set during rkp_start turns off signing verification altogether. The bug is in the bookkeeping, not the crypto.
Patch
June 2021 added the missing containment check to dynamic_load_check(): each code segment must lie fully within the binary. dynamic_load_add_executable(), dynamic_load_add_dynlist(), and dynamic_load_rm_dynlist() were unchanged because the upstream guard now prevented bad input from reaching them.
CVE-2021-25417 — Writing to read-only kernel memory
The third bug lives in rkp_ro_free_pages(), the freeing counterpart to RKP’s page allocator for kernel stage 1 page tables. The routine flips a range back to writable in stage 2 and returns it to the allocator, but does not check that the range was actually owned by the allocator in the first place. Stripped to its essentials:
int64_t rkp_ro_free_pages(uint64_t start_addr, uint64_t end_addr) {
// Missing validation that pages were allocated by RKP
if (rkp_s2_range_change_permission(start_addr, end_addr, 0, 1, 2) < 0) {
return -1;
}
return page_allocator_free(start_addr, end_addr);
}
Practically, this means an attacker can hand rkp_ro_free_pages() the physical addresses of kernel pages that RKP has marked read-only (because they contain .rodata, page tables, cred structures, etc.) and have the hypervisor obediently flip them writable in stage 2. From there, any subsequent EL1 write goes through. Samsung’s fix added a check via pgt_bitmap_overlaps_range() to reject any range that intersects pages already tracked as read-only page-table pages, and bounds-checked the range against the RKP allocator region.
Conclusion
Three independent bugs, all of the same shape: a security hypervisor that exposes multiple paths to modify stage 2 permissions, where the cheap path (single page) was hardened and the expensive path (a range) was not — and where the bookkeeping (physmap, protected_ranges, ro_bitmap, the page_allocator ownership) was not consulted consistently. RKP is a worthwhile defence in depth on Exynos Galaxy phones, but as Adamski’s research makes clear, an EL2 hypervisor is only as strong as the weakest validation across every permission-changing interface. The June 2021 patch for CVE-2021-25415 illustrates the point most cleanly: closing the per-page path while leaving the range path open meant the “fix” was bypassed by simply chaining the dynamic-load helpers, and the real fix had to wait until October. For defenders of similar designs — OEM security hypervisors, custom EPT shims, or virtualization-based protections on other platforms — the lesson is to treat every entry point to stage-2 permission changes as one surface, and to put the validation at the bottleneck, not at each caller.
Key Takeaways
- RKP is an EL2 stage-2 enforcer. Its main job is to prevent an EL1-compromised kernel from rewriting its own code, credentials, or page tables — or from reaching the hypervisor’s own pages.
- CVE-2021-25415 exploits a missing
S2UNMAPtag and a missing validation inrkp_s2_range_change_permission()to remap hypervisor memory as writable from the kernel. - CVE-2021-25416 exploits the lack of code-segment containment validation inside
dynamic_load_check(): any kernel page can be made RX by lying aboutcode_base1/code_size1. - CVE-2021-25417 exploits
rkp_ro_free_pages()never verifying that the freed range was actually allocated by RKP — allowing read-only kernel memory to be flipped writable in stage 2. - The first patch for CVE-2021-25415 was incomplete. Adamski binary-diffed both patches and found a new exploit path via the dynamic-load helpers that survived June 2021. Only the October 2021 update properly closed the range path.
- All three bugs require an attacker who already has kernel privileges. They are post-EoP RKP bypasses, not initial-access bugs — but for Android-kernel-exploit research, they are precisely the next hop attackers care about.
Defensive Recommendations
- Patch level. Affected Exynos Galaxy phones (S10/S10+, S20/S20+, A51 on Q/R) must be on the October 2021 SMR or later. June 2021 closes 25416 and the easy variants of 25415 but leaves the range-path exploit open.
- Audit similar EL2 designs. Any security hypervisor that exposes both per-page and per-range permission-change APIs should put the validation in a single chokepoint (or in the lowest-level descriptor write), not at each caller, to prevent “closed one path, missed the other” failures of the kind that gave CVE-2021-25415 a second life.
- Validate the ownership side, not just the destination. CVE-2021-25417 shows that “flip these pages writable” routines must check who allocated the pages, not just whether the destination range is well-formed.
- Treat “trusted” loader interfaces as untrusted input. CVE-2021-25416’s root cause is that
code_baseNwas taken on faith despite signed-binary verification only covering the binary itself. Loader designs should re-derive segment boundaries from the signed metadata, not accept caller-supplied addresses. - Monitor hypervisor self-protection invariants on test devices. If you build similar bookkeeping (a
physmap, aprotected_rangestable), red-team it by checking that every unmap path also writes the corresponding tag, and add periodic invariant checks at HVC boundaries. - For Android kernel exploit defenders: RKP is the safety net beneath kernel R/W primitives. If a kernel exploit appears on an unpatched device, an RKP-aware adversary will reach for one of these (or their successors) next — instrument SMRs and treat the “possible remapping” class of CVE titles as high-priority.
Timeline
- January 4, 2021 — CVE-2021-25415 reported to Samsung.
- January 5, 2021 — CVE-2021-25416 reported.
- January 2021 — CVE-2021-25417 reported (exact date not specified).
- June 2021 — First patch (SMR JUN-2021 Release 1). Closes CVE-2021-25416 fully, plus the per-page path of CVE-2021-25415. The range path remains exploitable via dynamic-load chaining.
- October 2021 — Second patch (SMR OCT-2021). Closes the remaining variants of CVE-2021-25415 by hardening
rkp_s2_page_change_permission()andrkp_s2_range_change_permission(), addingprotected_ranges_overlaps()and the per-pageS2UNMAPcheck loop. - November 25, 2021 — Article published on the Impalabs Blog by Alexandre Adamski.
This article is an independent English-language rewrite of “Attacking Samsung RKP” by Alexandre Adamski, originally published on the Impalabs Blog. The research was conducted at Longterm Security. All vulnerability research, reverse engineering, source-code excerpts, and ASCII diagrams remain the work of the original author. Please cite Alexandre Adamski / Impalabs when referencing this material.

