After my fun time with Dangerous Dave I've decided to look for more opportunities to play around with Real Mode assembly.
It's just so happens that Binary Golf Grand Prix is happening!
For those of you who are unfamiliar, Binary Golf Grand Prix is a competition to generate small files that do a specific task.
This year (2023) the task is simple: self-copy to a file called 4 and either output or return 4. You can do it in a shell script, Python etc.
Obviously scripting would be easy, but I've decided to go with COM!
COM files are type of executable files used throughout various DOS operating systems, including MS-DOS. They do not have any headers and are just loaded to memory at address ip=0x100. Their code segment, data segment and stack segments all have the same value.
These facts make COM files very attractive for minimalism, as we can literally write code immidiately with no headers and use the fact they're loaded to a predefined address. Obviously I intend to use interrupts extensively to get file operations done.
My plan is simple - use software interrupts (mostly DOS API that usually revolves around int 21h) and minimize assembly encoding. Specifically:
- Create a file called
4. - Write the contents to the file. Since we already know where to copy from (
0x100) and how much to copy (the file size) - this should be straightforward. - Write one character -
4. - Teminrate program.
As usual, I plan to use NASM as my Assembler of choice.
There are some nice optimizations I discovered while doing this challenge:
- It's better to use
xchg bx, axthanmov bx, axsince it's encoded as a single byte... Of course, if you don't care aboutax. - The program is loaded to
0x100immidiately after a block called Program Segment Prefix (or PSP for short), which contains some useful information. - It's better to use
int 0x20thanint 0x21to temrinate the program since you don't have to set any other registers. - The memory after the program is filled with zeros. Since I needed the NUL terminated string
4\x00it saved me one byte. - There's very good documentation regarding initial register values when your program starts running (here). Specifically, I used the fact that
siis0x100to my benefit, and the initial value of0xFFassigned toCXhad to be dealt with. - Interrupts generally maintain register values. I use that for my benefit.
- Since the stack initially contains a zero word, I can
retand that jumps to address0at the PSP, which encodesint 0x20. This is fully reliable and takes one byte intead of 2!
Here's my code, followed by some explanations:
;
; Make4.asm
;
; Constant - the file size
FILE_SIZE EQU (eof-$$)
; All COM programs start at 0x100
org 0x100
; BA 17 01
mov dx, filename ; Saves the filename to create in DX
; 91
; B4 5B
; CD 21
xchg ax, cx ; File attributes (sets to 0 as 0xFF is invalid - http://justsolve.archiveteam.org/wiki/DOS/Windows_file_attributes)
mov ah, 0x5B
int 0x21 ; DOS interrupt 21,5B - Create File
; 93
; B1 18
; 89 F2
; B4 40
; CD 21
xchg bx, ax ; File handle (XCHG takes one less byte to encode)
mov cl, FILE_SIZE ; Bytes to write (CH is already 0)
mov dx, si ; Buffer to write (SI never changed and points to 0x100)
mov ah, 0x40
int 0x21 ; DOS interrupt 21,40 - Write To File
; B8 34 0E
; CD 10
mov ax, 0x0E34 ; Character + interrupt number
int 0x10 ; BIOS interrupt 10,0E - Write character
; C3
ret ; DOS interrupt 20 - Terminate Program through PSP
; 34
filename: db '4' ; Maintains the filename (saves the NUL terminator since post-program chunk if full of zeros)
eof:Let's examine it:
FILE_SIZEis just a constant, like#definein C, and lets me reuse that value later. It does not encode as any bytes on its own.org 0x100is a directive that tells NASM to assume program is loaded at that address. It does not encode as any bytes.mov dx, msgreadies thedxregister to save the filename to create. This is a costly instruction that takes 3 bytes!- I use
xchg ax, cxwhich takes one byte (generallyxchgtakes 2 bytes butaxregister encoding makes it 1 byte). Since the initial value ofcxis0xffand sincecxis used as the file attributes to create, I cannot use0xff(see here). - I set
ahto0x5Band callint 21h, which creates a file with the filename pointed bydxand the attributes incx. - I use
xchg bx, axsince I need the file handle inbxfor the next interrupt. It's expected that the handle number is going to be5, but I wasn't able to use that fact to lower the number of encoded bytes - thatxchgtakes a single byte due toaxbeing present. cxis set to the file size. Note I use the factchis already zero - therefore usingmovonclalone, which takes one less byte.- I need to set
dxto0x100, and do so withmov dx, si, which takes one less byte. I use the factsinever got modified and it's initially0x100. - I assign
0x40toahand call the DOS interrupt21hagain, which writes to the file handle given atbx. It writes the amount of bytes incx, from the buffer pointed bydx. - I assign
ahto0x0eandalto the character4in one go - by assigning0x0E34toax. Then I callint 10hwhich is a BIOS interrupt that writes the character inalto the terminal. - I quit the program by calling
int 20hbut in a special way - since the initial stack has a zero, doing aretinstruction jumps to address0. Well, as I mentioned - that's where the PSP is, and it must start with the bytesCD 20which encodeint 20h- saving one byte. - Note the data at the end encodes
4for the filename without a NUL terminator - I use the fact that the program is loaded to an area full of zeros to save one extra byte.
The entire program takes 24 bytes - not a bad start!
You can compile with the following command:
nasm -f bin -o MAKE4.COM make4.asmOne thing I noticed is that I printed 4 but could easily just return 4, which saves one instruction...
Instead of printing 4 and calling int 0x20, I could just use DOS int21h, vector=4C:
mov ax, 0x4C04 ; Return value of 04 is in AL
int 0x21The problem is that DOSBox seems to set ERRORLEVEL to either 1 or 0, rather than actually using the return value.
However, DOSBox-X accurately does this - we use IF ERRORLEVEL instructions to distinguish exit codes since %ERRORLEVEL% variable is not well defined:
MAKE4.COM
IF ERRORLEVEL 5 ECHO NO
IF ERRORLEVEL 4 ECHO YESThis should output YES.
This gives us a total of 23 bytes!
;
; Make4.asm
;
; Constant - the file size
FILE_SIZE EQU (eof-$$)
; All COM programs start at 0x100
org 0x100
;
; BA 16 01
mov dx, filename ; Saves the filename to create in DX
;
; 91
; B4 5B
; CD 21
xchg ax, cx ; File attributes (sets to 0 as 0xFF is invalid - http://justsolve.archiveteam.org/wiki/DOS/Windows_file_attributes)
mov ah, 0x5B
int 0x21 ; DOS interrupt 21,5B - Create File
;
; 93
; B1 17
; 89 F2
; B4 40
; CD 21
xchg bx, ax ; File handle (XCHG takes one less byte to encode)
mov cl, FILE_SIZE ; Bytes to write (CH is already 0 due to previous XCHG instruction)
mov dx, si ; Buffer to write (SI never changed and points to 0x100 - http://www.fysnet.net/yourhelp.htm)
mov ah, 0x40
int 0x21 ; DOS interrupt 21,40 - Write To File
;
; B8 04 4C
; CD 21
mov ax, 0x4C04 ; Return value of 04 is in AL
int 0x21 ; DOS interrupt 21,4C - Terminate Program
; 34
filename: db '4' ; Maintains the filename (saves the NUL terminator since post-program chunk if full of zeros)
eof:- One more idea that I had (which wasn't successful) is to somehow save the repetition of
int 0x21- we do it 3 times so it costs 6 bytes.
One idea that I had was living off the land: in the PSP at offset0x50we seeUnix-like far call entry into DOS (always contains INT 21h + RETF). TheINT 21hpart fits us perfectly, butRETFis problematic. In fact, even if I could magically make itC3(normalRET) it'd still require at least2bytes each time toCALL(calling an address takes3bytes, calling a register takes2bytes). - A similar idea was to reuse addresses we know such as
0:0(the Interrupt Vector Table). Again - too costly to use. - I still had hopes that somehow I could magically call
INT 20hand affect the return code. I examined the DOSBox-X source code and it seems the handler forINT 21h,4CcallsDOS_Terminatewith theDLvalue as the exit code. This seems to be the only viable way to save the exit code. - The DOS interrupt 21 with
AH=2writes the character atDLas output, and it just so happens the initialflagsis set to2, so thelahfinsstruction (which takes a single byte) could be useful here. However, there is no gain here as we still need to callINT 21h(again) and thenret. - I thought of writing directly to memory instead of outputting
4. You can write a character to0xB800:0and it will "magically" appear on the screen due to MMIO. Unfortunately, I was not able to encode the relevant instructions and gain anything. Also, I was not sure if this is counted as a true output because it does not really write toSTDOUT, so it doesn't work if the caller redirects output (let's say, to a file). - Using
INT 2Ehseemed like a smart move - it gets commands to execute inCOMMAND.COM! However,COPY * 4does not work well - apparently you have to runCOPY *.* 4which ends up not saving a lot of bytes. Trying to print out4or returning an exit code of4ends up with more bytes. The minimum I got was23bytes.
Looking at Ralf Brown's Interrupt List I discovered INT 29h - Fast Console Output. It will output the character in the AL register and does not require setting any other register values, which is great for my efforts to minimize the payload length. This means we can use the RET idea to return to PSP address 0 after printing. This is still 23 bytes, but at least runs on DOSBox:
;
; Make4.asm
;
; Constant - the file size
FILE_SIZE EQU (eof-$$)
; All COM programs start at 0x100
org 0x100
;
; BA 16 01
mov dx, filename ; Saves the filename to create in DX
;
; 91
; B4 5B
; CD 21
xchg ax, cx ; File attributes (sets to 0 as 0xFF is invalid - http://justsolve.archiveteam.org/wiki/DOS/Windows_file_attributes)
mov ah, 0x5B
int 0x21 ; DOS interrupt 21,5B - Create File
;
; 93
; B1 17
; 89 F2
; B4 40
; CD 21
xchg bx, ax ; File handle (XCHG takes one less byte to encode)
mov cl, FILE_SIZE ; Bytes to write (CH is already 0 due to previous XCHG instruction)
mov dx, si ; Buffer to write (SI never changed and points to 0x100 - http://www.fysnet.net/yourhelp.htm)
mov ah, 0x40
int 0x21 ; DOS interrupt 21,40 - Write To File
; B0 34
; CD 29
mov al, '4' ; Character to write in AL
int 0x29 ; DOS interrupt 29 - Fast Console Output
; C3
ret ; Hack - returns to address 0 which has the PSP and effectively runs DOS interrupt 20 - Terminate Program
; 34
filename: db '4' ; Maintains the filename (saves the NUL terminator since post-program chunk if full of zeros)
eof:Can we do better? Note we encode the character 4 twice! Well, I was able to get to 22 bytes with the following changes:
- Replace the
mov dx, siwithxchg dx, si. This still encodes as2bytes, but now makessipoint to the last byte of the file (where the character4is). Of course,dxstill gets the0x100value fromsi, which is the desired effect. - Replace
mov al, '4'withlodsb! That instruction takes1byte only and assigns the value ofds:[si]toal.
Here is the new code:
;
; Make4.asm
;
; Constant - the file size
FILE_SIZE EQU (eof-$$)
; All COM programs start at 0x100
org 0x100
;
; BA 15 01
mov dx, filename ; Saves the filename to create in DX
;
; 91
; B4 5B
; CD 21
xchg ax, cx ; File attributes (sets to 0 as 0xFF is invalid - http://justsolve.archiveteam.org/wiki/DOS/Windows_file_attributes)
mov ah, 0x5B
int 0x21 ; DOS interrupt 21,5B - Create File
;
; 93
; B1 16
; 87 D6
; B4 40
; CD 21
xchg bx, ax ; File handle (XCHG takes one less byte to encode)
mov cl, FILE_SIZE ; Bytes to write (CH is already 0 due to previous XCHG instruction)
xchg dx, si ; Exchange DX and SI (SI never changed and points to 0x100 - http://www.fysnet.net/yourhelp.htm)
mov ah, 0x40
int 0x21 ; DOS interrupt 21,40 - Write To File
; AC
; CD 29
lodsb ; Load the last byte of the file in AL - was set when previously exchanging DX and SI
int 0x29 ; DOS interrupt 29 - Fast Console Output
; C3
ret ; Hack - returns to address 0 which has the PSP and effectively runs DOS interrupt 20 - Terminate Program
; 34
filename: db '4' ; Maintains the filename (saves the NUL terminator since post-program chunk if full of zeros)
eof:This is how it looks like this when running:
The file's contents are already captured in the code here, but here are the contents for good measure:
ba 15 01 91 b4 5b cd 21 93 b1 16 87 d6 b4 40 cd 21 ac cd 29 c3 34
I've uploaded the source code as make4.asm.
Besides COM, there were other Binary Golf targets, including:
This might look naive, but here's what I got:
#!
cp * 4
echo 4This takes 16 bytes to code. The interesting part here is the missing #! (Shebang).
Another idea here is to use * to copy - this assumes my script runs alone in the directory.
Also, vim and other editors usually finish with a linebreak (after echo 4) so I made sure to remove it.
I've uploaded the source code as sh4.sh.
The idea here was to use the same concepts from the shell script submission but without destroying meaningful register values or memory.
For example, let's look at our own submitted shell script - its bytes (23 21 0a 63 70 20 2a 20 34 0a 65 63 68 6f 20 34) decode into the following instructions:
and sp, [bx + di]
or ah, [bp + di + 0x70]
and [bp + si], ch
and [si], dh
or ah, [di + 0x63]
push 0x206fThis is quite destructive - the first instruction effectively assigns 0 to sp (not too bad), but the 4th one trashes the beginning of our program (because si points there - 0x100). This is quite problematic since it means we'll have to restore that value!
Luckily, we can slightly modify our shell script:
- Use other characters instead of line breaks - e.g.
;or|. - Add NUL terminators in some places. Apparently, they are ignored sometimes.
- Can call
exit 4instead ofecho 4. - Use other whitespaces (
\tinstead of).
I was able to get to the following script:
;
; shcom4.asm
;
; Constant - the file size
FILE_SIZE EQU (eof-$$)
; All COM programs start at 0x100
org 0x100
;
; Shell script
; Encodes to garbage commands that ultimately just zero the SP register:
; 23 21 and sp, [bx+di]
; 0A 63 70 or ah, [bp+di+0x70]
; 09 2A or [bp+si], bp
; 09 34 or [si], si
; 7C 65 jl 0x0170
; 78 69 js 0x0176
; 74 20 je 0x012F
; 34 00 xor al, 0x00
; 0A 23 or ah, [bp+di]
db '#!', 0x0A ; Empty interpreter still must be followed by "\n"
db 'cp', 0x09, '*', 0x09, '4|exit ' ; Using "\t" as a separator to avoid destroying program code pointed by SI
filename:
db '4', 0 ; Adding a NUL terminator (fine by bash) to reuse later in COM code
db 0x0A, '#' ; Finishing with a "\n" and a remark
;
; 91
; BA 0F 01
; B4 5B
; CD 21
xchg cx, ax ; Assign 0 to CX
mov dx, filename ; Saves the filename to create in DX (reuse data from shell script)
mov ah, 0x5B
int 0x21 ; Create the file with DOS interrupt 21,5B
;
; 93
; B1 16
; 87 D6
; B4 40
; CD 21
xchg bx, ax ; File handle (XCHG takes one less byte to encode)
mov cl, FILE_SIZE ; Bytes to write (CH is already 0 due to previous assignment of 0 to CX)
xchg dx, si ; Exchange DX and SI (SI never changed and points to 0x100 - http://www.fysnet.net/yourhelp.htm)
mov ah, 0x40
int 0x21 ; DOS interrupt 21,40 - Write To File
; AC
; CD 29
lodsb ; Load the last byte of the file in AL - was set when previously exchanging DX and SI
int 0x29 ; DOS interrupt 29 - Fast Console Output
; CD 20
int 0x20 ; DOS interrupt 20 - Terminate Program
eof:This file weighs 41 bytes but can run both as a COM file and a Linux shell script. Some important notes:
- I had to finish the shell script with a line-break and a remark, otherwise
bashtries to interpret the rest as further commands and writes weird errors tostderr. - I had to call
int 0x20directly, sincespwas assigned0due to the shell script being interpreted as assembly instructions. In fact,rettries to return to address 0x20CD (which is theCD 20that appears at the beginning of the PSP), which crashes the program.
Anyway, I've uploaded the source code as shcom4.asm.
This has been a fun challenge! We have covered a lot of cool tricks to minimize our payload, including:
- The structure of COM files and using some interesting initial register values.
- Using the PSP to return to address 0 and calling
INT 20h. - Using
xchginstead ofmovcan cost one less byte ifaxis involved. - Using
lodsbto read a byte intoal. - Looking for interesting interrupts at Ralf Brown's Interrupt List.
- Using uninitialized (yet effectively zero'd) memory to our advantage.
- Empty Shebang tricks and shell scripts using different whitespaces.
Thanks for sticking around!
Jonathan Bar Or
