| Release date | Event | Event kind | Category | Solve stats |
|---|---|---|---|---|
| June 7, 2025 | ECSC Team Italy Qualifier 2025 | Jeopardy, individual | pwn | 5/182+ |
Did you know that to correctly sum 64-bit integers you need 65 bits? That always annoyed me. That's why I am building a CLI calculator that can sum integers of arbitrary size! Wanna try it out? Here's a beta version I have been working on.
nc bigmistake.challs.quals.teamitaly.eu 38072
We are dealing with a simple CLI calculator with support for signed integers of arbitrary size, written in C++11 and compiled for x86-64 Linux. The ELF executable is compiled with most modern exploit mitigations enabled (NX, PIE, full RELRO, stack canaries) and linked against GNU glibc and libstdc++. It is however compiled without optimizations and with symbols and DWARF debug info. This makes reverse-engineering its functionality easier than usual for a C++ binary.
Numbers are parsed and handled via a BigInt class that can manage signed
integers of arbitrary size. This class only holds two private fields (see
BigInt.h): a sign_ to hold the integer sign and a
data_ vector of unsigned 64-bit integers to hold the absolute value of the
integer:
class BigInt {
int sign_;
std::vector<uint64_t> data_;
}For simplicity, the class constructor that takes a std::string only accepts
hexadecimal digits with an optional leading sign (+ or -) and converts them
into integers 60 bits (15 hex digits) at a time, storing their values into
the data_ vector in little-endian form (the 60 most significant bits are at
the end of the vector).
The calculator functionality is implemented via a Calculator class that is
responsible for parsing and evaluating statements taken from the program's
main() one line of input at a time.
Two kinds of statements are accepted:
- Expressions: simple mathematical expressions in infix notation consisting of
numbers in hexadecimal, variable names, and only two operators:
+and-. - Variable assignments: statements of the form
varname OP expression, whereOPcan be one of=,+=, or-=. The expression is evaluated and associated with the specified variable name. In the case of+=or-=the expression result is added or subtracted from the variable if it already exists.
Example of interaction:
> x = aaaaaaaaaaaaaaaaaaaaaaaaaaa
> x + 112233440000000011223344
aaabbccddeeaaaaaaaabbccddee
> x + 1234 - 1000
aaaaaaaaaaaaaaaaaaaaaaaacde
A Calculator class, whose only instance is in the main() function, holds one
internal unordered_map field to store values associated with variables, with
variable names (strings) as keys and raw BigInt * pointers as values. It also
implements 3 private and one public methods:
class Calculator {
std::unordered_map<std::string,BigInt *> vars_;
bool eval_one(const std::string &v, BigInt *&out) const;
BigInt *eval_expr(const std::deque<std::string> &expr) const;
void eval_assign(std::deque<std::string> &stmt);
public:
BigInt *eval(const std::string &stmt);
};The eval() method takes a std::string statement as input and splits it on
ASCII spaces into a std::deque<std::string>, which is then parsed. Depending
on the value of the second element, either eval_assign() or eval_expr() are
invoked. The result is either a nullptr for assignments or a BigInt *
pointer to a dynamically allocated BigInt for expressions.
All operations on BigInt values are performed using public operator methods
implemented by the BigInt class. Most notably Bigint::operator+(other) and
BigInt::operator-(other) implement addition and subtraction: these two
operators always return a new BigInt instance.
Assignment (BigInt::operator=(other)) is not explicitly implemented as the the
default C++ implicit assignment operator works just fine, while compound
assignment operators BigInt::operator+=(other) and BigInt::operator-=(other)
implement in-place addition and subtraction modifying the LHS BigInt instance
itself.
In Calculator::eval_assign(), the variable name is extracted and looked up in
the vars_ map. If not present, a BigInt is dynamically allocated via new
and default-constructed to have a value of 0. A pointer to it is then inserted
into vars_. The right-hand side of the statement is parsed via
Calculator::eval_expr() like a normal expression and then used to modify the
BigInt that was just retrieved or allocated via =/+=/-=.
In Calculator::eval_expr() the input std::deque of strings is parsed two
elements at a time as it is expected to be of the form val OP val OP .... Each
value is parsed via Calculator::eval_one(), performs a lookup into the vars_
map, returning a pointer to an existing BigInt (if present) or to a newly
dynamically allocated one, constructed parsing the value. Addition/subtraction
is then performed (depending on the encountered operators) via
BigInt::operator+(other) or BigInt::operator-(other). After evaluating the
whole expression, a BigInt * pointer to the result is returned.
The main() program simply calls std::getline() and Calculator::eval(line)
in a loop displaying the resulting BigInt (if any) to standard output
with std::cout::operator<< (implemented as a friend method by BigInt.
There are a few different bugs in the program, of which only one represents an actual vulnerability.
The Calculator::eval_one() method returns a BigInt * (via the BigInt *&out
reference parameter) that can either be the result of a new allocation or a
pointer to an existing variable value taken directly from the internal vars_
map. To inform the caller of the difference, eval_one() returns true if the
returned pointer represents an existing variable or false if it's just a
temporary value that can be safely freed later.
The Calculator::eval_expr() partially takes this information into account,
freeing returned values only if needed while iterating through the expression.
However, in case the expression only consists of a single element, no check is
performed on the return value of eval_one() and the BigInt * pointer is
returned as is.
The Calculator::eval() method always returns the BigInt * pointer obtained
from eval_expr() as is. This pointer is then always deleted in main()
freeing the underlying BigInt object. In the case of a temporary expression
results, this is fine. However, in the case of existing variables, this is
wrong. The deleted pointer still exists in the vars_ map, and this can easily
lead to Use-After-Free if the same variable is requested again in a future
expression.
Other insignificant bugs include:
- Ignoring operators that are not
+or-inCalculator::eval_expr()instead of bailing out, resulting in broken expressions like1 . 2 asd 3 xxx 4with unexpected resulting values. - Leaking memory in
Calculator::eval_assign()due to the RHS of the assignment returned byeval_expr()never being freed.
We are dealing with a UAF on a BigInt class that includes a std::vector
field. If corrupted, a std::vector can provide a very powerful arbitrary
read/write primitive. First, though, we need a leak to defeat ASLR.
To leak a heap address, we can allocate a bunch BigInt objects defining some
variables, then request the variables again with a bunch of single-element
expressions to free the underlying BigInt and fill tcache. Once tcache is
filled, we can free some more (at least two) to get them into a fastbin and have
heap pointers on the heap. Heap addresses will then appear inside the backing
store of one of the freed std::vector objects. If we request the right
variable we will get a heap pointer nicely printed out for us.
To have a libc address on the heap, we can define a variable with a large value
(at least 0x400 hex digits) to force consolidation of existing fastbin chunks,
move them into the unsorted bin and subsequently into other bins like smallbin
depending on the size. This will put the address of glibc's main_arena on the
heap. We can then use the same strategy we used for the heap leak.
Alternatively, now that we know where the heap is located, we can also first
build an arbitrary read/write primitive via UAF on a corrupted BigInt (see
below), and then use it to read the main_arena pointer.
To achieve arbitrary r/w, we can reclaim a BigInt object (32 bytes) from
tcache with the backing store of a std::vector for a new variable of the right
size (at least 181 bits i.e. 46 hex digits). This will lead to the following
situation:
New BigInt Victim BigInt
o-----------------o -->o-----------------o
| sign_ | / | sign_ |
| data_.start |---' | data_.start |
| data_.end |---. | data_.end |
| data_.alloc_end |----\ | data_.alloc_end |
o-----------------o -->o-----------------o
The start, end and alloc_end are private fields of std::vector (the
internal libstdc++ field names are _M_impl._M_start, _M_impl._M_finish and
_M_impl._M_end_of_storage). They are all pointers: start points to the first
element in the backing store, end points one past the last one, and
alloc_end points one past the last available allocated slot (it is equal to
end when the vector is full). If we control their value, then we can
read/write to arbitrary addresses via UAF on the victim variable (whose BigInt * is still in the vars_ map).
Given that only 60 bits are stored in each int64_t of the data_ vector, some
helper functions to make the conversion easier are needed:
def to_bigint(data: list[int]) -> int:
value = 0
shift = 0
for v in data:
# Top nibble will be lost
assert v < (1 << 60)
value |= v << shift
shift += 60
return value
def fake_bigint(data_ptr: int, size: int) -> int:
return to_bigint([1, data_ptr, data_ptr + size, data_ptr + size])We can now reclaim a previously freed BigInt from tcache and fully control its
internal fields with a crafted value:
value = fake_bigint(ADDR, SIZE)
r.sendlineafter(b'> ', f'pwn = {value:x}')pwn BigInt Victim BigInt
o-----------------o -->o-------------------------------o
| sign_ = 1 | / | sign_ = 1 |
| data_.start |---' | data_.start = ADDR |
| data_.end |---. | data_.end = ADDR + SIZE |
| data_.alloc_end |----\ | data_.alloc_end = ADDR + SIZE |
o-----------------o -->o-------------------------------o
After discovering the name of the corresponding victim variable, we can:
- Read SIZE bytes from ADDR using the victim variable name in an expression. The
expression must contain more than one element (e.g.
victim + 0) as referencingvictimalone would double-free theBigIntand most likely crash the program due to glibc heap sanity checks. - Write up to SIZE bytes to ADDR easily via
+=or-=. We can also use simple+and-but need to be careful about the implicit object copies.
Similarly, to move around the vector pointers of the victim variable, changing
ADDR and SIZE, we can operate on pwn with the += and -= operators. We just
need to take care when constructing values to add or subtract, because if we add
or subtract too much, the backing store for the data_ vector of pwn will
increase or decrease in size. To remedy this, we can always first read the
victim's value, then calculate the amount to add or subtract as needed.
Now that we have achieved ARW, scoring an ACE should be trivial:
- Read
environfrom libc to get the location of the stack. - Calculate
main()'s return address location on the stack. - Write a ROP chain on the stack starting from
main()'s return address. - Make the calculator error out with an invalid value to trigger a return from
main().
See expl.py for the final automated exploit script.