Shadow: Dummy folders, an obvious overflow, and a ROP chain
Intro
quick note from oct 25 ava: some of the sections where line breaks are important for legibility do not have the line breaks they should have. not sure why, i'll see if i can fix this when possible. until this note is removed, reading this article may be a little difficult in some places. sorry about that!
The recommended song for this article is Introspective by LAAKE. It accurately represents how fun working on this jailbreak was.
Shadow is my third, and most recent Kindle jailbreak. It's also by far the most complicated Kindle jailbreak to date. The reason the jailbreak is named Shadow is because it was, quite literally, going to be in the shadows for months. Unfortunately, the jailbreak was patched in the 5.20.1 update. Originally, I thought I was going to can the jailbreak, but I decided to release it for two reasons: number one, it's cool; number two, it's always good to have alternatives to working jailbreaks. As of now, Shadow is the second jailbreak to work on firmwares >=5.19.6.
Two final notes: all comments are preserved from the source, unless prefixed with "ava -", and all memory addresses are only from one run of the exploit. Have fun!
Kindle MTP Overview
Alright, so first, what even is MTP? Is it that "Multi-Token Prediction" thing that's apparently important for selfhosting ai or something? Or is it "Medical Termination of Pregnancy"..? Nope, don't think that's it either. OH! It's the Media Transfer Protocol!
Essentially, upon the release of the 2024 Kindles, Amazon switches Kindles from using usb-mass storage transfer (where your computer/phone sees the Kindle as a mass-storage device, similar to a USB stick), to Media Transfer Protocol (MTP). Specifically, it uses the tizen-mtp implementation of MTP. Fortunately for us, this just so happens to be open-source, from Collabora's fork of the Tizen mtp-responder. It can be found here, and will be referenced numerous times throughout this writeup.
Basically, rather than directly interfacing with the files, MTP acts as an intermediary between the file system and the client. It was made with data safety in mind, bla bla, we don't really care. What's important are these concepts:
- Containers: Every request is sent with a container, which contains all the information about the request being made--container length in bytes, type (command, data, response, event), opcode, transaction ID, and then finally, the payload. When, for example, a file is transferred, there are separate containers for the command, and for data.
- Objects and handles: All files and folders are called "Objects" by MTP. These objects are then referred to internally by their handles, rather than by their paths. These handles are 32 bits.

Now, here's what we're up against for exploiting MTP into executing arbitrary code:
- Position Independent Executable (referred to as PIE throughout the rest of this guide) -- This, combined with ASLR (mentioned later) means that the tizen-mtp executable sits at a random point in memory every time it's ran. This is an obstacle that other jailbreaks, such as SpiderCat did not have to face, as the
webreaderbinary sits at a fixed memory address. Essentially, we do not know the locations of the functions intizen-mtpin memory, until we defeat the ASLR/PIE protections. - Address Space Layout Randomization (ASLR) -- Does exactly what it says on the tin, again, this means that we have to leak memory addresses and then derive the location of what we need. We can't hardcode locations of the information we need.
tizen-mtpruns as root. This means we do not need a separate privilege escalation exploit to run jb.sh as root, any code ran bytizen-mtpwill run as root.tizen-mtpcarries it's ownsystem()function. This means we do not need a primitive to read an arbitrary memory address, or even multiple memory addresses. Typically, you would leak the base oftizen-mtp, read the GOT to find an importedlibcsymbol, then find thelibcbase, then, finally, find the address ofsystem()in memory. With this, finding the base oftizen-mtpin memory is enough, as we can then just add the offset ofsystem()to getsystem()'s location in memory.
All in all, the last two things mentioned on their own are terrible security choices, but I'm not a developer. Who am I to judge?
The two vulnerabilities
Two vulnerabilities are chained together in the exploit, and fortunately, both were fairly easy to find on their own.
- Uninitialized timestrings
In src/entity/mtp_property.c, some properties for objects are initialized:
mtp_bool _prop_update_property_values_list(mtp_obj_t *obj)
{
mtp_uint32 ii = 0;
mtp_char guid[16] = { 0 };
slist_node_t *node = NULL;
slist_node_t *next_node = NULL;
ptp_time_string_t create_tm, modify_tm;
mtp_uint32 fmt_code = obj->obj_info->obj_fmt;
To anyone who's ever heard of the C programming language, one thing should blatantly stand out: create_tm (time a file was created) and modify_tm are not zero-initialized, unlike all of the other surrounding values. There is essentially no reason to do this, and it is the core of the memory leak bug.
We can get more information about this structure in ptp_datacodes.h, on line 764 and 815-820, respectively.
#define MAX_PTP_TIME_STRING_CHARS 20 /* eg: 20050526T171236 */
[...]
typedef struct {
/* Num of chars in string including the NUL */
mtp_uchar num_chars;
/* Holds actual Unicode string with 2 bytes chars,'\0' terminated*/
mtp_wchar str[MAX_PTP_TIME_STRING_CHARS]; /* ava - 20 wchars, 40 bytes */
} ptp_time_string_t;
Basically, the structure of the stack slot the struct is put into is as follows, in bytes: [1, Length][Padding][40, string buffer]. This is rounded to 44 bytes by the compiler for alignment purposes.
How this is actually exploited will be explored later, but that is the core bug that allows for the infoleak to happen.
- A container overwrites its own loop bound
As mentioned before, command containers are used to send commands to MTP. I hope that by this stage in life, you are familiar with what commands are. Now, let's check how this command container structure is defined in src/ptp_container.h
#define MAX_MTP_PARAMS 5
#define MTP_USB_HEADER_LENGTH 12
[...]
typedef struct {
mtp_uint32 len; /* the valid container size in BYTES */
mtp_uint16 type; /* Container type */
mtp_uint16 code; /* Operation, response or Event code */
mtp_uint32 tid; /* host generated transaction id */
mtp_uint32 params[MAX_MTP_PARAMS];
mtp_uint32 no_param;
} cmd_container_t;
Note that params are four bytes, so each container is meant to be structured as follows: [Bytes 0x00-0x0B: Header]-[Bytes 0x0C-0x1F: params[0-4]]-[no_param]]
Now, let's look at how this is actually validated in src/ptp_container.c
mtp_bool _hdlr_validate_cmd_container(mtp_uchar *blk, mtp_uint32 size)
{
if (size < sizeof(header_container_t) || size > sizeof(cmd_container_t))
return FALSE;
Now, looking at the second part of that if check, we can see something interesting: sizeof(cmd_container_t) is 36 bytes, but the last four bytes are meant to be no_param, not data that we send. What can we do with this?
Well, let's look at _hdlr_copy_cmd_container_unknown_params (the function to stage the container) in the same file:
("12" and "4" filled in by me)
dst->no_param =
(src->len - 12) / 4; /* ava - (36-12)/4, so 6 for a 36-byte container */
for (ii = 0; ii < dst->no_param; ii++) /* ava - bound is re-read, ii = 5 overwrites it */
dst->params[ii] = src->params[ii];
Basically, this calculation allows for us to insert six parameters. Fortunately for us, param[5] sits at the same offset within the container as no_param, allowing us to overwrite no_param in the for loop to whatever we want, allowing us to copy as many dwords as we want into the stack slot (eg, 0x10 if we want it to copy 16 dwords).
Chaining, exploiting, and complicating
Now, it's time to chain both of these vulnerabilities together to execute OUR code.

The Leak
So, above, we established that there's an uninitialized timestring, with a compiled struct size of 44 bytes. Let's look at how the timestring is actually grabbed, to see if we can get the MTP daemon to return the timestring when it doesn't exist. If we can do this, it will return raw stack, which is the leak we need to chain with the stack buffer overflow bug.
Firstly, we are led to the caller of the function that fills in create_tm in src/entity/mtp_property.c:
_entity_get_file_times(obj, &create_tm, &modify_tm); /* ava - return is ignored here */
This function is then defined in src/entity/mtp_object.c:
if (FALSE == _util_get_file_attrs(obj->file_path, &attrs)) {
ERR("_util_get_file_attrs Fail");
return FALSE;
}
Again, we go one level deeper, into _util_get_file_attrs_ in src/util/mtp_fs.c
if (stat(filename, &fileinfo) < 0) {
ERR_SECURE("%s : stat Fail errno [%d]\n", filename, errno);
return FALSE;
}
Now, the path is clear: if stat() fails, we can get raw, uninitialized stack back by asking an object when it was created. To make stat() fail, we have to fulfill two conditions:
- MTP believes that the object exists on the file system
- The object does not exist on the file system
Doing this is easier than it sounds: you simply send a SendObjectInfo request and then don't follow up with the upload, but we have to open a session first.
We send the following packet:
10 00 00 00 01 00 02 10 00 00 00 00 01 00 00 00
Translated, this is: len = 0x10, command, 0x1002 (OpenSession), tid =0, session = 1.
The daemon responds with:
0C 00 00 00 03 00 01 20 00 00 00 00
Again: len = 0x0C, response, 0x2001 (OK), tid = 0.
After opening the session, we could immediately make a SendObjectInfo request, but first, we must churn the allocator in order to get something useful into the slot that we read when we grab the object creation date.
We need to get something useful into the slot first: we perform a few operations in order to do this: (thanks to deepseek for finding this pattern, because good lord)
- Create 9 dummy folders in root (/mnt/base-us) each with 6-character random names:
- I don't think the character names here are that strict? I was also able to reproduce the same leak with 8-character names too, but going to 1 or 100 would probably fail as well.
- Create 3 folders inside of each other, each with a length of 199 characters.
- (name lengths in brackets) (A - {199})/(B - {199})/(C - {199}).
- Create a folder with name length 245 characters, then a folder with name length 200 characters inside of the 245 character folder.
- (D - {245})/(E - {200})
- Create a 4-char folder, create another 4-char folder inside of that folder
- (4char1 - {4})/(4char2 - {4})
- Move folder D (and consequentially, folder E) inside of folder C, such that the tree looks like:
- (A - {199})/(B - {199})/(C - {199})/(D - {245})/(E - {200})
Then, we can send the SendObjectInfo request:
18 00 00 00 01 00 0C 10 TT TT TT TT 00 00 00 00 FF FF FF FF 00 00 00 00
Translated: len = 0x18, command, 0x100C (SendObjectInfo), tid, store = 0 (use the default store location), parent = root (highest level location in that store), reserved.
We follow up with a data phase packet containing some basic information about the object: storage type, object size, dates/flags/parent, filename, bla bla bla. Not that interesting. The important part is what we receive back from the daemon after sending the data phase packet:
18 00 00 00 03 00 01 20 TT TT TT TT 01 00 01 00 <parent> <HANDLE>
Translated: len = 0x18, response, OK, tid, store = 0x10001, parent, handle.
This handle is stored by Shadow, as it's what we subsequently reference to ask the ghost file for it's creation date. We could ask this file for it's creation date now, but we would still only get garbage. Instead, we send GetObjectHandles calls prior to asking for the creation date. Again, this is to ensure that the right garbage is left behind in the stack slot. The structure of those calls looks like this:
GetObjectHandles(root) [all root-level objects], then GetObjectPropValue(ghost, 0xDC08) <- 0xDC08 is the code for creation date
GetObjectHandles(4char1) [children of 4char1], then GetObjectPropValue(ghost, 0xDC08)
GetObjectHandles(4char2) [handle of 4char2], then GetObjectPropValue(ghost, 0xDC08)
GetObjectHandles(all) [ALL objects in the store], then GetObjectPropValue(ghost, 0xDC08)
The raw packet of that final GetObjectPropValue (and all of them) looks like this:
14 00 00 00 01 00 03 98 TT TT TT TT <ghost> 08 DC 00 00
Translated: len = 0x14, command, 0x9803 (GetObjectPropValue), tid, the handle of the ghost file, 0xDC08 (Creation date)
Then, the daemon responds back with the jackpot ('|' added in as a divider):
09 | 00 00 | 00 E0 62 7F | 00 00 00 00 | 08 3A 02 B4 | C0 88 7F B0
Translated: Length of response (9 wchars following the length byte), padding, 0x7F62E000 (bottom of GOT), slot + 8 (blank), 0xB4023A08 (unused), 0xB07F88C0.
Before explaining what all of that means, I want to say that 9 landing there is pure luck (deterministic, I mean that we are lucky it landed there), because it tells the daemon to send us 9 wchars. Were it to be, say, 4, we would be unable to get a sufficient leak. Also, this is why the exploit has a minimum version of 5.19.4: a pointer is left in the first word of the slot, so the low byte used as the number of characters is rejected due to being too high.
Firstly, the GOT is the global offset table. It can be used for exploits, but we do not use it here. All you need to know is that it sits at a fixed address inside the binary, which can be grabbed via readelf (or bin/elfscan in the Shadow repo). Additionally, the GOT is always page aligned (at a multiple of 4096 bytes) within memory. In the response above, we got 0x7F62E000--Shadow checks that this value is page aligned by doing an & operation with 0xFFF and ensuring that it's zero. Once that's done, we can subtract the fixed address of the GOT within the binary from the page-aligned address leaked, to get the PIE base of the daemon.
Simply put:
PIE = pieWord - GOTbase
PIE = 0x7F62E000 - 0x2B000
PIE = 0x7F603000
As for 0xB4023A08, it's a heap pointer. We don't really care about it. What we DO care about is 0xB07F88C0, which is a pointer on the stack. We need to use this pointer to figure out the location of the receiver frame (the stack frame of the function that receives the command packet). Again, this is done with simple subtraction:
R - Receiver frame, P - pointer
R = P - 0xCD8
R = 0xB07F88C0 - 0xCD8
R = 0xB07F7BE8
You are almost surely (rightfully!) asking "How did you get 0xCD8? Where did that come from?" Simply put, it was measured from the internals of the program (vibeslopped emulator). The way it works is as follows:
- Run the binary offline via qemu-arm, use the device libc and simulated FunctionFS transport (how
tizen-mtpinterfaces with the linux file system) - Capture a leak hit and take the last four bytes of the output (
0xB07F88C0as shown above) - Put a breakpoint (stopping point in the program) in gdb at the copy function mentioned before (the site of the overflow,
_hdlr_copy_cmd_container_unknown_params) and readsp. Because the stack grows downward, this is the lowest memory address, so it is the base of this stack frame. - Compute: delta = P - R, for KT6, this always comes out to
0xCD8.
Finally, we have enough information to exploit the stack buffer overflow.
The Overflow
Again, fortunately, this is much simpler. We already have all the information we need, now we just need to exploit it in the right way in order to gain code execution.
Welllll, actually, there is one more piece of information needed: the source of the copy for hdlr_copy_cmd_container. Essentially, this comes from a 1KB buffer which is used, freed and recycled for each MTP command sent. Once this buffer is used, it's recycled through the tcache, the next read gets the other buffer chunk back. There are two of these 1KB buffers, and they are recycled for each command.
Now, the phase variable is what determines how an incoming packet is processed. We need the phase to be IDLE (1) and have a valid container, otherwise no copy will happen. The phases work like this:
- phase 1 + invalid container > phase 0, no copy
- phase 0 + any container > phase 1, no copy
- phase 1 + valid container > copy, this is what we need to exploit the stack buffer overflow
Since we've only sent valid containers so far (stays in phase 1), we send three filler packets. This goes as follows: phase 1 + invalid container > phase 0 + invalid container > phase 1 + invalid container > phase 0. The reason we send three containers instead of one is because we need BOTH buffers to have our payload, otherwise there would only be a 50% chance we land on the buffer container, AND because we need to be on phase 1 for the copy to happen successfully.
Now, the packets themselves:
- The filler
We make this packet invalid by giving it an invalid length (0x400/1024), but these bytes are still read into that RX buffer. The offset within this container maps to the offset within the stack frame according to the formula: S + 0x14 + o, so
0x48lands on the savedr4atS+0x5C0x4Con savedr70x50on the LR0x74atS+0x88
The packet is as follows:
0x48: 00 00 00 00 ; saved r4=0
0x4C: 3C 7C 7F B0 ; saved r7 = S+0x54
0x50: B1 7B 60 7F ; saved LR = PIE + 0x4BB1 = 0x7F607BB1
0x54: 00 00 00 00 ; r1
0x58: 01 2A 62 7F ; ava - r3 = PIE + 0x1FA01 = 0x7F622A01
0x5C: 00 00 00 00 ; r4
0x60: 00 00 00 00 ; r5
0x64: 00 00 00 00 ; r6
0x68: 03 DD 61 7F ; pc = PIE + 0x1AD03 = 0x7F61DD03
0x6C: 70 7C 7F B0 ; [r7 + 44] = string addr = 0xB07F7C70
0x74: "RUN_MODE=1 /bin/sh /mnt/base-us/jb.sh"
- Phase flip
We need to flip the phase from 0 to 1 for the copy to occur, we can do this with a basic packet. Not much interesting about it.
24 00 00 00 01 00 01 10 71 (00 until offset 32) 40 00 00 00
Translated: len=0x24, command, 0x1001 (GetDeviceInfo), tid=0x71 (arbitrary tid)
- Overflow!
Finally, after a long, perilous journey, we can trigger the overflow, which will give us our ROP chain, which will give us code execution. Let's look at the packet:
24 00 00 00 01 00 01 10 78 00 00 00 (00 until offset 32) 24 00 00 00
This copies 36 dwords (0x24) from source offset 12 onto the stack, the locations are derived via the formula laid out in #1.
Assemblyland!
Well, what did the overflow actually do? How does it give us code execution? Was Ava lying to us and just using her Véra exploit in the background the whole time? Of course! Just kidding, of course not. Essentially, the function that receives the MTP packet is _receive_mq_data_cb. Its C code isn't important, so I won't link to it on github. What IS important is the assembly instructions for its epilogue, look at this:
8a28: adds r7, #0x54
8a2a: mov sp, r7
8a2c: pop {r4, r7, pc}
This does two things for us: firstly, our copy function then overwrote the saved LR due to our overflow-inducing packet, so the saved LR is now our pop gadget address (PIE + 0x4BB1). Secondly, it restores the r7 to R+0x54 (you'll see why this is used in a second). This causes the program to jump to that instruction:
pop {r1, r3, r4, r5, r6, pc}
Essentially, this acts as a checkpoint where we can load our call target r3 (the system() in the binary, mentioned long, long ago). Then, it lets us jump to another instruction by popping pc (jumps to PIE + 0x1AD03), that instruction is:
ldr r0, [r7, #44]; blx r3
This sets r0 to be [r7 + 44], which itself is a pointer to our string (0xB07F7C70), it then branches to r3 (PIE + 0x1FA01, system()). Since r0 is the first argument, it's essentially called like: system([r7 + 44]), which ends up being system(RUN_MODE=1 /bin/sh /mnt/base-us/jb.sh).
Conclusion
Well, that's it. system() being called with RUN_MODE=1 /bin/sh /mnt/base-us/jb.sh runs the jailbreak. Now, if you're into the KindleModding community, you may think "wait, don't jailbreaks usually curl jb.sh?" and the answer is yes! Shadow physically cannot do that, because Wi-Fi is disabled while MTP is active. Instead, Shadow stores jb.sh on-device, and then has the user replug in order to get a fresh daemon for the exploit to run. If jb.sh is transferred within the same session, then the memory layout will not be suitable for the infoleak to occur.
Lastly, I'd like to thank a person in the KindleModding server for researching MTP back in January. He was the reason I learned about the stack buffer overflow, as well as the reason I started looking into this jailbreak in the first place. I did not tell him I was researching MTP until I finished my chain, so I did not use any code from him. Without knowing that there even were vulnerabilities in MTP, I would've thought MTP was a dead end.
I'm very glad that this jailbreak is able to see the light of day, I'll look back on it fondly.