Having fun with ANSI codes and x64 Linux Assembly

9 minute read Published:

Using ANSI escape codes with x64 Linux Assembly for command line fun
Table of Contents

Overview

How can one not find command line art amusing? Specially when we are talking about computer viruses and even more so when referencing MS-DOS ones. The 16 bit era gave us some of the most interesting computer virus payloads of all time, but achieving something like this today is not as “trivial” anymore.

As Linux is my OS of choice, I wanted to find something that could get close to these MS-DOS fun payloads for my own modern viruses, and, while it’s possible to write directly to the framebuffer, I wanted to try something related to terminal emulators instead. Enter ANSI escape sequences.

How it works

ANSI escape sequences are a standard for in-band signaling to control cursor location, color, font styling, and other options on video text terminals and terminal emulators. Certain sequences of bytes, most starting with an ASCII Escape and bracket character followed by parameters, are embedded into text. The terminal interprets these sequences as commands, rather than text to display verbatim.

ANSI sequences were introduced in the 1970s to replace vendor-specific sequences and became widespread in the computer equipment market by the early 1980s. They are used in development, scientific, commercial text-based applications as well as bulletin board systems to offer standardized functionality.

Read more: https://en.wikipedia.org/wiki/ANSI_escape_code

Lots and lots of things you use daily are probably using ANSI escape codes, every time you see colored text on your terminal. Text-based user interfaces, a.k.a TUIs need these control codes to “draw” what you see on your screen.

ESC is represented by the well known 0x1b control byte and the usage is as simple as using printf or echo to write the codes to STDOUT (keep in mind that the terminal you are using must support the ANSI sequences, look for termcap , terminfo and infocmp if you need).

There are some very good references for ANSI escape codes around the web, like the one at Bash Hackers Wiki.

Code

While ANSI sequences are rather easy to use in any modern programming language, the same cannot be said for Assembly, mainly because the manual work involved when dealing with strings (I’m talking about pure Assembly, without including any external functions like printf and without invoking tput).

Note that my code currently relies on ioctl Linux system call to manipulate special files (like terminals) but termios would be a better approach here instead and I have kept ioctl because I wanted to re-use some old code snippet I wrote long time ago, before I knew about termios. I imagine it should be fairly easy to make the change if you want to and I plan to do it in the future.

From ioctl_tty man page:

Use of ioctl makes for nonportable programs. Use the POSIX interface described in termios(3) whenever possible.

A little helper program written in C like the one below can be used display the terminal dimensions (rows and columns) with ioctl:

#include <sys/ioctl.h>
#include <stdio.h>
#include <unistd.h>

int main (void) {
  struct winsize ws;
  ioctl (STDIN_FILENO, TIOCGWINSZ, &ws);

  printf ("lines %d\n", ws.ws_row);
  printf ("columns %d\n", ws.ws_col);

  return 0;
}

Anyway, the example application I wrote will do the following:

  • Save current terminal buffer (ESC[?1049h)
  • Clear screen (ESC[2J)
  • Invoke ioctl syscall to retrieve current window dimensions (rows and columns)
  • Use of the result from ioctl and perform some math to create a cursor, setting its rows and columns (x, y) position to more or less the center of the screen (ESC[x;yH)
  • Loop using nanosleep and write syscalls to simulate a typewriter effect while we write our message to the screen, byte by byte, including any extra ANSI sequences for formatting

You can find the full commented source code with further instructions, all auxiliar functions and variable declarations on GitHub but let’s go over the mentioned key steps above to understand it better.

  • Saving current terminal buffer by writting ESC[?1049h (represented by save_buffer) to STDOUT
    lea rsi, [save_buffer]     ; loading rsi with ANSI code that saves current terminal buffer
    mov rdx, save_buffer_size  ; loading rdx with save ANSI code size
    mov rdi, STDOUT
    mov rax, SYS_WRITE
    syscall                    ; saving current terminal
  • Clearing the screen by writting ESC[2J (represented by clear_screen) to STDOUT
    lea rsi, [clear_screen]     ; loading rsi with ANSI code that clears screen
    mov rdx, clear_screen_size  ; loading rdx with clear screen ANSI code size
    mov rdi, STDOUT
    mov rax, SYS_WRITE
    syscall                     ; clearing the screen
  • Invoking ioctl syscall to retrieve terminal window size (this approach is not always guaranteed to give results and it’s safer to use it in conjunction to a lookup in the termcap database).

    xor rdi, rdi         ; this means fd will be STDIN (same as "mov rdi, STDIN")
    mov rsi, TIOCGWINSZ  ; ioctl command to get window size
    mov rdx, winsz       ; winsz struct will contain terminal size information
    mov rax, SYS_IOCTL
    syscall
    
    call create_cursor   ; creating ANSI code that moves to proper coordinates (result format: "ESC[x;yH")
​ It will populate winsz struct with the result values. More information on the TIOCGWINSZ command can be found here

struct WINSZ
    .ws_row     dw ?     ; rows, in characters
    .ws_col     dw ?     ; columns, in characters
    .ws_xpixel  dw ?     ; horizontal size, pixels
    .ws_ypixel  dw ?     ; vertical size, pixels
ends
  • Creating the initial cursor we will use as starting point to write our message. Basically we divide the screen rows and columns by 3 in order to get a nice centralized final output. Feel free to play around with this denominator for different results. The function below will construct the ESC[x;yH string into cursor_buffer and replace x and y with the result of our small division, rounding up if needed
create_cursor:
    lea rdi, [cursor_buffer]   ; loading [cursor_buffer] into rdi
    mov byte [rdi], ESC        ; the 'ESC' character
    inc rdi                    ; advance [cursor_buffer]
    mov byte [rdi], 0x5b       ; the '[' character
    inc rdi                    ; advance [cursor_buffer]

    mov rcx, 3                 ; loading denominator into rax
    mov r8w, [winsz.ws_row]    ; loading numerator (X axis = rows) into r8w
    call divideRoundUp         ; dividing r8w/rcx with result in rax

    cmp ax, [previous_axisX]   ; comparing X axis with previous X axis (zero during the first run)
    je .bad_axisX              ; if current X axis = previous X axis, we should recalculate it
    jg .all_good               ; else, we continue normally
    .bad_axisX:                         
        inc [winsz.ws_row]     ; increasing current X axis as its the same as previous one (same cursor position not allowed)             
        jmp create_cursor      ; recreating cursor ANSI escape code with proper coordinates
    .all_good:
    mov [previous_axisX], ax   ; save X axis after dividing and rounding up into [previous_axisX]

    mov rdx, rax               ; loading X axis into rdx
    call convertStoreAxis      ; converting X axis to ascii and storing in rdi (at current buffer position)

    mov byte [rdi], 0x3b       ; the ';' character 
    inc rdi                    ; advance [cursor_buffer]

    mov rcx, 3                 ; loading denominator into rax
    mov r8w, [winsz.ws_col]    ; loading numerator (columns) into r8w
    call divideRoundUp         ; dividing r8w/rcx with result in rax
    
    mov rdx, rax               ; loading Y axis into rdx
    call convertStoreAxis      ; converting Y axis to ascii and storing in rdi (at current buffer position)

    mov byte [rdi], 0x48       ; the 'H' character

    lea rdi, [cursor_buffer]   ; loading rdi with ANSI code that moves cursor ("ESC[x;yH")
    call strLen                ; calculating code lenght from [cursor_buffer], result in rax
    ret
  • Now that we have the calculated cursor_buffer, we need to write it to STDOUT, which will move our cursor to the desired coordinates
    lea rsi, [cursor_buffer]   ; loading rsi with ANSI code that moves cursor
    mov rdx, rax               ; loading rdx with proper code length (without trailing null character)
    mov rdi, STDOUT
    mov rax, SYS_WRITE         ; in this program, the cursor will be set to the center of the screen
    syscall                    ; moving cursor to (x, y)
  • What’s left now is to actually write our message to the screen, remember we are using nanosleep syscall to add some delay achieve a typewriter effect, so we will loop through our message byte by byte and write each byte to STDOUT. We also have to keep generating cursors with new (x, y) coordinates to move the input position around like a typewriter would (we can use create_cursor function again for that).
struct TIMESPEC
    .tv_sec     dq 0            ; seconds to sleep
    .tv_nsec    dq 060000000    ; nanoseconds to sleep
ends
.outputLoop:
    push rcx                     ; saving msg_size in stack because syscall overwrites it

    lea rsi, [msg + rbx]         ; loading rsi with msg[rbx] character
    mov rdx, 1                   ; length (rdx) is 1 since we are outputting a single byte at a time
    mov rdi, STDOUT
    mov rax, SYS_WRITE
    syscall                      ; prints msg[rbx] to STDOUT

    cmp byte [rsi], 0xa          ; checking if current character is new line (\n)
    jne .continue                ; if not, skip .moveCursor and continue
    .moveCursor:
        inc [winsz.ws_row]       ; incrementing X axis to account for new line
        call create_cursor       ; creating new ANSI code to move cursor to new coordinates

        lea rsi, [cursor_buffer] ; loading rsi with ANSI code that moves cursor
        mov rdx, rax             ; loading rdx with proper code length (without trailing null character)
        mov rdi, STDOUT
        mov rax, SYS_WRITE
        syscall                  ; moving cursor to (x, y)
    .continue:
        mov rdi, delay           ; loading rdi with delay struct that contains seconds and nanoseconds to sleep
        xor rsi, rsi             ; zeroing rsi because its not being used at this time
        mov rax, SYS_NANOSLEEP   ; since it would contain the remaining time of the sleep in case command is interrupted
        syscall                  ; sleeping for the amount of time configured in delay struct

        pop rcx                  ; restoring msg_size from stack into rcx
        inc rbx                  ; incrementing rbx index
        loop .outputLoop         ; repeat until whole msg is outputted
  • And finally we finish by adding a simple readline, prompting the user to press a key before we restore the original terminal session we saved in the beginning of our program
readline:
    lea rsi, [exit_msg]          ; loading rsi formatted exit message with some included ANSI codes
    mov rdx, exit_msg_size       ; loading rdx with exit message size
    mov rdi, STDOUT
    mov rax, SYS_WRITE
    syscall                      ; write exit message to the screen

    mov rdi, STDIN
    mov rdx, 1
    mov rax, SYS_READ            ; reading a single char from STDIN before restoring previous terminal buffer
    syscall                      ; pausing execution in the meantime

restoreTerminal:
    lea rsi, [restore_buffer]    ; loading rsi with ANSI code to restore previous terminal buffer
    mov rdx, restore_buffer_size ; loading rdx with restore ANSI code size
    mov rdi, STDOUT
    mov rax, SYS_WRITE
    syscall                      ; restoring previous terminal (from before running this program)

Demo

As we can see, using ANSI sequences with Linux Assembly can be a bit challenging, but can also produce very interesting results that might bring back that MS-DOS (albeit not in its full glory) era nostalgia on my future projects.

TMZ

comments powered by Disqus