DEV Community

Lozi
Lozi

Posted on

Bounce Buffer | Staging Buffer

While I was translating a decryption tool from C++ to Rust, I stumble across a really weird syntax that kept popping me up for hours.

Context

The function that I'm about to show you, it's purpose is to prepare the pointer for the decryption process using the KIRK encryption algorithm:

int kirk1block(const u8 *pbIn, u8 *pbOut)
{
    static u8 g_dataTmp[0x1040] __attribute__((aligned(0x40)));
    memcpy(g_dataTmp+0x40, pbIn, 0x1000);
    int ret = sceUtilsBufferCopyWithRange(g_dataTmp, 0x1040, g_dataTmp+0x40, 0x500, 1);
    if (ret != 0) {
        return ret;
    }
    memcpy(pbOut, g_dataTmp, 0x1000);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Ok don't get scared, let's understand what exactly this function was made for. First of all let me introduce a really quick summary what exactly is "kirk".

What is KIRK?

KIRK is a dedicated piece of hardware inside PSP that is respondible for permorfing cryptographic operations, such as encryption, decryption, and hashing.
So instead of having the main CPU from the PSP to perform all of those operations itself, software can communicate with KIRK and ask it to perform a certain cryptographic operation. Then KIRK processes the data and returns a result.
You can think of it as a specialize cryptographic processor rather than a general-purpose CPU.

And this function was made in order to use THAT PSP'S KIRK cryptographic engine.

Since KIRK was built for maximizing speed and not safety, it physically requires memory to be processed in a strict way: hypothetically 64 byte chunks (and also aligned to addresses ending in 0x00, 0x40, 0x80, and so on...). NOW you understand where this is going:

So because KIRK is hardware rather than a simple software function, it contains certain requirements about how data must be presented to it. Therefore some KIRK operations expects their input and outbut buffers to be a 64 bytes aligned and proccessed in a specific block sizes (that's why I say hypothetically, since this is not a universal truth).

This, in other words, means that a buffer might need to start at an address such as:

  • 0x1000 → aligned
  • 0x1040 → aligned
  • 0x1080 → aligned

And because each of these addresses is a multiple of 0x40 (64).

KIRK has a strict memory alignment and block size requirements in order to perform certain operations. So by passing a buffer that doesn't satisfy those requirements can cause the operation to fail or produce incorrect behavior.

Now that we know why we need alignment, we can explain what is Bounce Buffer

So if KIRK is that strict about memory, we could run into a massive problem when we look at our function arguments:

int kirk1block(const u8 *pbIn, u8 *pbOut)
Enter fullscreen mode Exit fullscreen mode

Our encrypted data is being sent throuhg const u8 *pbIn. Since data comes from any adress in the program, we don't have guarantee that pbIn starts at an address that is multiple of 64. Therefore if it starts at 0x1013 for example, and we feed it directly to KIRK, the whole system could crash.
Because of that we can't trust the input address, so we have to create a safe middle-man. In low-level programming, this is known as a Bounce Buffer (or Staging Buffer).

Bounce Buffer | Staging Buffer

In simple terms, a bounce buffer is exactly just like what it sounds like: A temporary but highly controlled workspace. So if you have data being stored in an unpredictable, bad or incompatible spot (like out pbIn pointer), we don't use it directly. Instead we create a new buffer, then "bounce" our data into it, let the hardware do it's job safely and then bounce the clean results to their final destination.

Since we are the ones creating this temporary buffer, we can dictate exactly where it lives inside memory. Therefore we can force it in order to follow KIRK's strict alignment rules!!!
And that's exactly what that weird syntax I faced is doing:

static u8 g_dataTmp[0x1040] __attribute__((aligned(0x40)));
Enter fullscreen mode Exit fullscreen mode
  • g_dataTmp[0x1040]: We are creating an array of bytes that's sightly larger than the 0x1000 chunk of data that we desire to process (Soon I'll explain why "sightly larger", just follow me)
  • __atribute__ ((aligned(0x40))): This is us telling to the C compiler a rule!!! We are explicitly telling this (or somewhat...): "I don't care where you put other variables, but this specific array MUST start at a memory address that is multiple of 64!!!".
  • static: Well, If you know C you'll probably know this, but basically means that the buffer isn't destroyed when the function ends. Therefore it's only created once in permanent memory, meaning that we are not gonna waste CPU time clocks rebuilding that workspace every time we process a block of data.

Then once our aligned workplace is ready, the next step will be moving the data in and then move it to our desired destination once it finishes processing data KIRK.

But why +0x40?

Now that our workbench is built, let's look at the actual data movement. Here are the next lines of our function:

memcpy(g_dataTmp+0x40, pbIn, 0x1000);
int ret = sceUtilsBufferCopyWithRange(g_dataTmp, 0x1040, g_dataTmp+0x40, 0x500, 1);
Enter fullscreen mode Exit fullscreen mode

Let's look closely at the first memcpy. Since we aren't copying our encrypted data at the start of our buffer. We are copying it into g_dataTmp + 0x40 (In other words: We are skipping the first 64 bytes!!).

Remember when I told you our buffer was sightly larger (0x1040) than the data we wanted to process (0x1000)? This is the explanation about this manner if you are interested:

The original PSP only had 32 MB OF RAM!!!. And memory was extremely limited because of that. So instead of wasting space by creating a two separated aligned arrays (one por the input and one for the clean output), the programmer used an old-school trick which is called In-Place Decryption. They used the same exact array for both!!!! (I have an article explaining this concept, you should watch it).
But then why a 64-byte gap? Why not just start at 0?

This is because of the Read / Write Race.
If we started at the exact line, we would have a massive problem. Since te Write Head might step on the Read "Rail", therefore it can overwrite a byte of encrypted data just before the hardware had a chance to read it. So the data would corrupt itself!!!

By putting the encrypted data 64 bytes ahead, we are giving the Read Head a massive head start. Instead of hoping for the best and pray it doesn't overwrite encrypted data, we are securing it in a much better way. So that 64-byte empty buffer zone acts as a safety caution, making sure the Write Head always stays safely behind the Read Head while the hardware does its thing.

Top comments (0)