Reversing DOS functions: PADD and PSUB

These two are both intertwined so it makes sense to analyse them together:

image.png

We can see a few variations on names at the top here: N_PADD@ and F_PADD@ for PADD, and N_PSUB@ and F_PSUB@ respectively. The prolog for the near versions is the same; it pops the return offset, pushes CS and then pushes the return offset back so it doesn't matter whether the call is a near or far call, we can always make a successful far return.

It's also useful to look how this is called:

image.png

So it looks like we're taking DX:AX and CX:BX as arguments, and the result is returned in DX:AX. Let's start going through the code:

or      cx, cx
jge     short loc_1E24E

What the hell is this? The JGE instruction jumps if SF = OF. So we need to know what SF is, what OF is, and then whether they're equal. Looking closer at the OR instruction, we see it clears OF and sets SF to the high bit of the result. So we know that OF = 0, so we'll make the jump if SF = 0, or if CX is a positive number. There's another opcode JNS that could work here but it's all the same - we jump if CX is positive (and likely, if CX:BX is positive). Let's follow this branch:

loc_1E24E:
add     ax, bx
jnb     short loc_1E256

We're returning the result in DX:AX so it makes sense to add BX into AX. We know CX:BX is positive, so we start by adding the lower word. JNB jumps if CF = 0 (no carry), so let's see what we do with the carry:

add     dx, 1000h
loc_1E256:

This is fun. If DX:AX was a 32-bit value we'd be adding 1 to DX, but we're adding 0x1000 instead. That makes me think that DX:AX is a segment:offset pair and we're adding to that. Let's look further:

mov     ch, cl
mov     cl, 4
shl     ch, cl

We destroy CH here! We then shift CL left by 4 bits, so we destroy the top 4 bits of CL too. This makes sense if CX:BX is a 32-bit number, and if we're adding this to a segment:offset pair then the bottom 20 bits (all of BX plus the bottom 4 bits of CL) make sense to add. In other words, if CX:BX was 000a:bcde, we now have CH = a0.

add     dh, ch

Remember that 32-bit location 000abcde can be converted to segment:offset a000:bcde (among others). We added the lower part (bcde) to AX already, carried the 1, and now we're adding the top 4 bits to DX. DX:AX now holds the final segment:offset, but there's more code to go:

mov     ch, al
shr     ax, cl
add     dx, ax

We're saving the low byte of DX:AX, shifting AX right by 4 bits, then adding onto DX. What does this mean? It bumps the segment part to the highest value.

mov     al, ch
and     ax, 0Fh

Then we put the low byte in AL and mask it so we have the largest segment possible, and the lowest offset possible, while still pointing to the same point in memory.

In other words, this branch does the following:

// add the amount to the segment:offset
offset += lowword;
if(carry)
  segment += 0x1000;
segment += (hiword & 0x0F) * 0x1000;
// adjust segment:offset
segment += offset >> 4;
offset = offset & 0x0F;

The segment arithmetic is always hard to follow, but this is the basic idea of what happens there.

We only have 3 more branches to go here :) Let's take a quick look at the corresponding PSUB that gets us here:

or      cx, cx
jge     short loc_1E27D
not     bx
not     cx
add     bx, 1
adc     cx, 0
jmp     short loc_1E24E

If CX:BX is negative then we bit flip CX:BX, increment BX by 1, and then load the carry into CX. The reason they use the ADD command is that INC doesn't change CF so the following ADC wouldn't carry the 1. Why do we do this? This is what we call "two's complement" and this is just gives us the negative value, in other words:

CX:BX = -CX:BX

So if we're subtracting a negative number, this is the same as adding a positive number, e.g. 5 - -3 is the same as 5 + 3 so we'll use the addition path. The opposite is true on the addition path btw, if we're adding a negative number we'll just negate it and go down the subtraction path.

Anyway, let's see what the subtraction path does:

loc_1E27D:
sub     ax, bx
jnb     short loc_1E285
sub     dx, 1000h
loc_1E285:

This is the opposite of the addition path, we do AX - BX and then carry the 1 by subtracting 1000 from DX. Note that this can go negative if we have a weird segment:offset pair like 0000:F000 that could be better phrased as 0F00:0000.

mov     bh, cl
mov     cl, 4
shl     bh, cl

Same as above, if BX:CX was 000a:bcde we now have BH=a0

xor     bl, bl
sub     dx, bx

This is actually the same as above but for some reason they've done DX - BX instead of DH - BH - it does the same thing when BL is 0

mov     ch, al
shr     ax, cl
add     dx, ax
mov     al, ch
and     ax, 0Fh

And apart from the weirdness with BX this is the same segment:offset adjusting code from above.

I've used this signature for IDA: __int32 __usercall __far N_PADD_@<dx:ax>(int sSource@<dx>, void near* pSource@<ax>, __int32 addend@<cx:bx>);

And then it gives me this output:

image.png

Hope you enjoyed this, happy reversing!