Linux X86 Assembly - How to Build a Hello World Program in NASM

Linux X86 Assembly – How to Build a Hello World Program in NASM

Overview

A processor understands bytecode instructions specific to that architecture.  We as humans use mnemonics to make building these instructions easier than remembering a bunch of binary codes.  These mnemonics are known as assembly instructions.  This is one of the lowest levels of programming that can be done.  This programming is a bit of a lost art and I remember trying to learn about it, there was not much material on the internet regarding it.  This series is designed to discuss some basic tutorials and to give examples for people to get started with when it comes to building X86 assembly on Linux-based systems.

Why Though?

Assembly translates almost 1-to-1 with it’s binary bytecode.  There are several high level languages for programming that are far simpler than assembly, but assembly is still worth learning.  Assembly allows you control of the CPU and its registers directly.  This can be used to write very small and very fast processes.  Another advantage of assembly is that what you write is usually exactly what you get in the binary format, where higher level languages such as C/C++ will usually add extra boilerplate code to set things up.

Finally, in the information security community we often need to use disassembly to analyze malware samples, exploit payloads, and perform general reverse engineering.  A strong understanding of assembly will go a long way in making sense of this information.  Working knowledge of Assembly is also needed if you want to make your own custom payloads, which can be both fun and rewarding!

Prerequisite Knowledge

Before continuing, this article will assume you have some basic knowledge of the X86 architecture.  A blog post that covers the basics of the X86 architecture can be found here.

Getting Started – Toolchains & Source Code Syntax

There are several toolchains that can be used.  For X86 the two default options are the GNU Assembler (GAS) or the Netwide Assembler (NASM).  In this tutorial, we will focus on NASM to show a working example.  It is worth noting that the syntax will NOT be compatible between these two compilers.  The primary difference is in the operand ordering and referencing conventions between the compilers.  GAS uses AT&T syntax and NASM uses Intel syntax.  This results in differences in how the source code must be formatted so they aren’t cross-compatible.

Making Syscalls on X86 Linux

Syscalls offer a method to invoke and use functionality in the operating system.  Syscalls are launched on x86 Linux by invoking an interrupt instruction with the hex value of 0x80 (int 0x80).  Before invoking this interrupt however, you need to set up everything for the syscall.  It is worth noting the calling convention for syscalls are different on other architectures, this would even include x86_64 which uses the SYSCALL instruction instead of INT 0x80.  This tutorial is focused on 32-bit x86.

A flowchart of the process of setting up a syscall in x86 Linux.

The first thing you need to do is specify the syscall number you are planning to invoke and place that value in EAX.  Depending on the syscall you are launching, you also need to set up arguments for the syscall to use.  These will also be loaded in registers in the following order.

Parameter # Register
Parameter 1 EBX
Parameter 2 ECX
Parameter 3 EDX
Parameter 4 ESI
Parameter 5 EDI
Parameter 6 EBP

After the syscall number is set, and the registers are loaded with the parameters needed, then calling the instruction INT 0x80 will execute the syscall.  If the syscall returns a value, it will be placed in EAX.  For example, opening a file should return a file descriptor or error code in EAX.

How to Find Information on Syscalls

So earlier we mentioned that we need to load a syscall number into EAX for the syscall we want to invoke, but how do we know which numbers relate to the syscalls?  There are several ways to obtain this information.  Here are a few methods:

  • Linux Kernel Headers
    • The most absolute source of truth, but also not the easiest method to get what you’re looking for
  • Web resources

Build a basic Hello World Program

The goal is to build a basic hello world program in x86 in Linux using just syscalls.  For this we will need two syscalls, which are outlined in the table below:

Syscall # Syscall Name Parameters Reason
4 write EBX => int fd
ECX => char* buf
EDX => size_t count
This call allows us to write x number of bytes (count) to a file descriptor (fd) from the buffer (buf). 

We can use this to write to STDOUT should have the fd of 1.
1 exit EBX => int status This syscall allows us to exit the program cleanly with the status code of our choice.

In this case, we will exit with the status code of 0.

If you look at the way these syscalls are set up and the parameters, they basically mirror the libc functions that match their names.  These functions in libc are generally wrappers to these syscalls.  As a result, the command man 2 <syscall> can usually help explain what the parameters are, what behavior to expect, and possible return values with these syscalls.  For example, the following commands would pull up the manuals for these syscalls:

  • $ man 2 write
  • $ man 2 exit

For reference, the following C code gets us close to what we will be writing in x86 assembly.

/****************************************************
*
* Program: hello_world
*
* Date: 04/20/2021
*
* Author: Travis Phillips
*
* Purpose: A simple hello world written in C that
*          mimics what we are going to write in x86
*          ASM using write and exit calls.
*
* Compile: gcc -m32 hello_world.c -o hello_world_c
*
****************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

const char *msg = " [*] Hello World!\n";

int main() {
    // Write the message to STDOUT.
    write(1, msg, strlen(msg));

    // Exit with status code 0;
    exit(0);
}

Which we can compile, check the size, run, and use strace, which dumps a list of syscalls a program invokes, to see how it functions as shown in the following screenshot.

compiling, running, and analysis of the hello world program written in C.

This basic Hello World in C came out to be ~15kb in size, which isn’t bad for a program that hasn’t had it’s debugging symbols stripped.  However the strace shows a lot of extra syscalls that we didn’t invoke directly.  This is C boilerplate code that’s run during the loading process before main() is invoked.  Towards the end of all of this, we finally see our write syscall, and an exit_group syscall instead of an exit syscall.  This is all here just for reference so we can compare it against what we build later in assembly.

Source Code

Time to write our assembly for NASM!  Let’s first list our complete code here for the hello_world_nasm.asm file, then we will pick it apart to explain what is going on.  The complete code is as follows:

;***************************************************************
;
; Program: hello_world_nasm
;
; Date: 04/20/2021
;
; Author: Travis Phillips
;
; Purpose: A simple hello world program in x86 assembly for
;          NASM
;
; Compile: nasm -f elf hello_world_nasm.asm
;    Link: ld -m elf_i386 hello_world_nasm.o -o hello_world_nasm
;
;***************************************************************
global _start     ; global is used to export the _start label.
                  ; This will be the entry point to the program.

   ; The data segment of our program.
section .data
   msg: db "Hello, World!",0xa ; Declare a label "msg" which has
                               ; our string we want to print.
                               ; for reference: 0xa = "\n"

   len: equ $-msg              ; "len" will calculate the current
                               ; offset minus the "msg" offset.
                               ; this should give us the size of
                               ; "msg".

   ; The .text segment of our program, counter-intuitively, this
   ; is where we store our executable code.
section .text
_start:
   ;######################################
   ; syscall - write(1, msg, len);
   ;######################################
   mov eax, 4     ; 4 = Syscall number for Write()

   mov ebx, 1     ; File Descriptor to write to
                  ; In this case: STDOUT is 1

   mov ecx, msg   ; String to write. A pointer to
                  ; the variable 'msg'

   mov edx, len   ; The length of string to print
                  ; which is 14 characters

   int 0x80       ; Poke the kernel and tell it to run the
                  ; write() call we set up

   ;######################################
   ; syscall - exit(0);
   ;######################################
   mov al, 1      ; Syscall for Exit()
   mov ebx, 0     ; The status code we want to provide.
   int 0x80       ; Poke kernel. This will end the program

The code above might look massive, but it’s mostly comments to help explain things.  If we remove the comments, the code part would be much smaller and simply be:

global _start
section .data
   msg: db "Hello, World!",0xa
   len: equ $-msg
section .text
_start:
   mov eax, 4
   mov ebx, 1
   mov ecx, msg
   mov edx, len
   int 0x80
   mov al, 1
   mov ebx, 0
   int 0x80

The first chunk of our source code file is a comments section.  This is mostly just project information for us.

;***************************************************************
;
; Program: hello_world_nasm
;
; Date: 04/20/2021
;
; Author: Travis Phillips
;
; Purpose: A simple hello world program in x86 assembly for
;          NASM
;
; Compile: nasm -f elf hello_world_nasm.asm
;    Link: ld -m elf_i386 hello_world_nasm.o -o hello_world_nasm
;
;***************************************************************

Nothing major here.  In NASM, anything following a semicolon (;) is considered a comment.  This top part of our file is just some basic information for humans that are looking at the file.  The next line that follows however exports our _start label so the compiler will make it visible to the linker to use as an entry point.  The lines responsible for that is as follows:

global _start     ; global is used to export the _start label.
                  ; This will be the entry point to the program.

After this is the line that marks the start of our .data section of the ELF binary.  The syntax on this code is pretty straightforward.  You would state the keyword “section” followed by the name of the section.  This .data section we will use to store our variables. 

   ; The data segment of our program.
section .data

In the data section, we will create two of them.  The first one is “msg”.  The “msg:” part is actually creating this as a label.  A label provides a means for us to access an offset with a friendly name, and the compiler will actually implement the real offsets at compile time.  To create a label you place a string, which is the label’s name at the start of a line, followed by a colon (:).  This can be seen with the “len” and “_start” labels in the complete code listing.  After declaring the label, we use the “db” keyword which means “declare bytes”.  This let’s NASM know that what follows isn’t instructions, but rather a collection of specified bytes.  In our case we are providing the ASCII string “Hello, World!” and a newline byte (0xa).

   msg: db "Hello, World!",0xa ; Declare a label "msg" which has
                               ; our string we want to print.
                               ; for reference: 0xa = "\n"

The second variable we declare in the .data section is “len”.  Take notice that it FOLLOWS the msg variable.  This is important because this line is performing an offset calculation to determine the size by taking the label len offset and subtracting the msg label offset.  This would effectively give to the size of the data between the two labels.

   len: equ $-msg              ; "len" will calculate the current
                               ; offset minus the "msg" offset.
                               ; this should give us the size of
                               ; "msg".

Now that those two variables are created, which we will use for the write syscall, we can now end the .data section and start the .text section.  Oddly enough, the .text section is where you would place the executable instruction code for the program.  We can simply start this section with the section keyword like we did earlier:

   ; The .text segment of our program, counter-intuitively, this
   ; is where we store our executable code.
section .text

Now that we are in the .text section, we need to declare that _start label we exported as the first line of code.  We simply use the following line of code to accomplish this:

_start:

Now we can finally start writing the assembly code we want to execute!  First we will start by looking over the chunk of code that creates and invokes the write syscall to print our hello world message:

   ;######################################
   ; syscall - write(1, msg, len);
   ;######################################
   mov eax, 4     ; 4 = Syscall number for Write()

   mov ebx, 1     ; File Descriptor to write to
                  ; In this case: STDOUT is 1

   mov ecx, msg   ; String to write. A pointer to
                  ; the variable 'msg'

   mov edx, len   ; The length of string to print
                  ; which is 14 characters

   int 0x80       ; Poke the kernel and tell it to run the
                  ; write() call we set up

The top three lines are just a comment banner that makes it easy if we ever have to edit this again to understand what we were doing with the following code chunk.  As mentioned earlier, the first thing we will do is set EAX to the syscall number we want to invoke.  As mentioned in our previous section, we found the syscall number on 32-bit x86 Linux for the write syscall was 4.  We use a MOV instruction (short for move) to load the immediate value of 4 into the register EAX, which needs to contain the syscall number we are looking to run.  The syntax for the move instruction in NASM is “MOV <destination>, <source>”.  This is one of the areas where GAS and NASM differ.  In GAS, the source and destination parameters would be flipped.

The next 3 instructions are to set parameters for the write syscalls.  The register EBX needs to contain the file descriptor integer that we want to write to, ECX needs to contain a pointer to the data buffer we want to write to the file descriptor, and EDX needs to contain the number of bytes from the data buffer we want to write out to the file descriptor.  It’s pretty straightforward using MOV instructions to do this.  However, you can see that we are using the msg and len variables we created earlier to make this a little easier for us.  Using these variables makes it so you could easily update the message and len should automatically update as well.

Finally, now that the syscall for write is set in EAX and the parameters for the write syscall are also set up, we invoke the INT 0x80 instruction.  This will let the system know that we need it to execute a syscall for us and it will use the values we set up in the registers to do so.  Once the OS runs the syscall, the “Hello World!\n” message should be written to the console on STDOUT and we can move on to our next syscall of exit which is shown below:

   ;######################################
   ; syscall - exit(0);
   ;######################################
   mov al, 1      ; Syscall for Exit()
   mov ebx, 0     ; The status code we want to provide.
   int 0x80       ; Poke kernel. This will end the program

The code here should look familiar since it’s just MOV and INT instructions.  There is one thing we did differently here which was use the AL register instead of EAX.  Since the write would have put the number of bytes written by the write syscall, it should only be a single byte since it was a small message.  This would mean AL should be able to load the exit syscall of 1 into EAX just fine.  The reason for doing this is that MOV EAX, 1 is actually a 5 byte instruction, where MOV AL, 1 is only 2 bytes.  We will take a look at that later in this blog with objdump.

Compiling and Linking

The comment block at the top of the code explains how to compile and link this code.  It’s pretty straightforward with the following two commands:

$ nasm -f elf hello_world_nasm.asm
$ ld -m elf_i386 hello_world_nasm.o -o hello_world_nasm

To make things even easier, we can add the following text to a Makefile and leverage the make command to build both the C and NASM projects for us.

all: hello_world_nasm hello_world_c

hello_world_nasm:
        nasm -f elf hello_world_nasm.asm
        ld -m elf_i386 hello_world_nasm.o -o hello_world_nasm

hello_world_c:
        gcc hello_world.c -o hello_world_c

clean:
        rm hello_world_c hello_world_nasm hello_world_nasm.o

With that Makefile in place, you can build both the C and NASM version by simply running the make command in the directory as shown below:

Using Make to build our NASM Hello World program

Comparing the C and NASM Programs

As shown in the last screenshot, our NASM program is about half the size as the C binary.  What’s more important is that the NASM binary is also lighter when run.  It does exactly what we told it to do.  If we run both of these side-by-side with strace to view the syscalls we can see the NASM binary is much more direct and to the point than its C counterpart.

comparing strace output of our NASM and C Hello World applications

Another difference is what the main and _start code blocks look like in our programs.  What we wrote in assembly, is almost EXACTLY  what we get in the decompiler output, with the biggest difference being that the compiler and linker resolved the msg and len labels directly to what they needed to be.  We can see this by using the objdump (short for Object Dump) command.

object dump output of our NASM Hello World program

Looking at the objdump output, we can see that this is almost exactly what we wrote.  The object dump is using a different syntax for display, that’s closer to what GAS would use (the AT&T vs Intel syntax covered earlier in this blog).  This is why the parameters appear to be flipped from dst-src to src-dst.  The table below shows the code we wrote versus the disassembled binary code for the _start code block.  You can see it’s almost the same except our variables have been expanded out.

What we wrote What it became once compiled
mov  eax, 4
mov  ebx, 1
mov  ecx, msg
mov  edx, len
int  0x80
mov  al, 1
mov  ebx, 0
int  0x80
mov    $0x4,%eax
mov    $0x1,%ebx
mov    $0x804a000,%ecx
mov    $0xe,%edx
int    $0x80
mov    $0x1,%al
mov    $0x0,%ebx
int    $0x80

We can run objdump on the C and it has a lot more going on, but we will just compare our _start code in NASM vs the main() code of the C project:

Comparing the object dump out of the NASM and C based Hello World programs

So we have to ask ourselves.  If NASM produced leaner code and does exactly what we told it to do instruction wise, why even mess with C?  Well several arguments for that:

  • C is a higher level language
    • Easier to learn than ASM
    • The programmer has less responsibility over memory management
      • Stack frames are managed by the compiler as opposed to having to be manually set up by ASM programmers
      • It’s much easier to create memory corruption bugs in ASM than it is in C and C actually gets a lot of heat for being bad about memory corruption bugs
  • This NASM code works ONLY on 32-bit X86 CPUs
    • This won’t work on a ARM-based Raspberry Pi without an emulator
    • The C code example is more portable than the ASM code, because GCC will work just fine with compiling our C code on other CPU architectures
  • ASM doesn’t scale easily for large projects
    • While printing hello world was easy, imagine trying to build a mature DB engine or full-featured web server in ASM

That said ASM can be very fast and small.  Knowing ASM can help you write shellcode for custom payloads and assist in reverse engineering efforts.  Even though decompilers are getting better, they still fall flat sometimes, and when they do, disassembly will be your source-of-truth, but only if you know how to read it.

Conclusion

I hope you’ve enjoyed this blog post and learned something new today about the x86 assembly.  In future posts, we will look to repeat this hello world in GAS instead of NASM to cover both toolchains, and look into what we could do to optimize this code further and make it “shellcode safe” so it could be a viable payload for use in memory corruption exploit.

Ready for a challenge?  We post Mystery Challenges on Facebook, LinkedIn, and Twitter.  If you’re interested in security fundamentals, we have a Professionally Evil Fundamentals (PEF) channel that covers a variety of technology topics.  We also answer general basic questions in our Knowledge Center.  Finally, if you’re looking for a penetration test, professional training for your organization, or just have general security questions please Contact Us.

Scroll to Top