Google Groups no longer supports new Usenet posts or subscriptions. Historical content remains viewable.
Dismiss

Working code for runtime Vtable alteration (MS-Windows, Linux)

46 views
Skip to first unread message

Frederick Gotham

unread,
Jun 11, 2020, 10:00:43 AM6/11/20
to
I started a thread here three days ago about altering the VTable of a class at runtime:

https://groups.google.com/forum/#!topic/comp.lang.c++/q_QY4zNnLJ4

That thread has over a hundred posts now going in various directions so I've created this new thread to specifically look at the following sample program. Note that I'm editing the VTable for the class, rather than altering the pointer-to-Vtable which exists inside any given object.

First of all, here's a sample program for printing incrementing numbers to the screen:

#include <chrono> // milliseconds
#include <thread> // this_thread::sleep_for
#include <iostream>
using std::cout;
using std::cin;
using std::endl;

struct NumberPrinter {
long unsigned i;
virtual void Print(void) = 0;
};

struct DecimalNumberPrinter : NumberPrinter {
void Print(void) { cout << std::dec << i++ << endl; }
};

struct HexadecimalNumberPrinter : NumberPrinter {
void Print(void) { cout << "0x" << std::hex << i++ << endl; }
};

auto main(void) -> int
{
NumberPrinter *p;

cout << "Enter 1 for Decimal, or 2 for Hexadecimal: " << std::flush;

unsigned choice;
cin >> choice;

if ( 2 == choice )
p = new HexadecimalNumberPrinter;
else
p = new DecimalNumberPrinter;https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible

p->i = 0;

for ( ; /* ever */ ; )
{
p->Print();

std::this_thread::sleep_for(std::chrono::milliseconds(100u));
}
}


And so next I want to add code to the loop in 'main' to simulate the firing of an interrupt (which then synchronously calls the function Interrupt_Routine and then hands control back to main).

The following code compiles and runs properly on Linux using the GNU compiler, and also on MS-Windows using the Visual C++ compiler.

I _do_ have an understanding of how different compilers handle pointers to methods, as explained here:

https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible

However I _don't_ fully understand how and why I was able to get the code to work for MS-Visual (for instance I don't know why it worked when I added the magic number 88 to the address). So if anyone can look further into this with me and help me understand then I'd greatly appreciate it!

On the website, "rextester.com", this code works properly for GNU and VC++. It fails for 'clang' because the two method pointers are equal in value (I got this same behaviour on VC++ and had to work around it as you can see).

When you run this program, if you select (1) for decimal, then it should print 5 numbers in decimal format, the it will switch to hexadecimal for the next 5, and then switch back to decimal. It will keep alternating like that for every 5 iterations.

extern bool Check_For_Interrupt(void); // Defined below main
extern void Interrupt_Routine(void); // Defined below main

#include <chrono> // milliseconds
#include <thread> // this_thread::sleep_for
#include <iostream>
using std::cout;
using std::cin;
using std::endl;

struct NumberPrinter {
long unsigned i;
virtual void Print(void) = 0;
};

struct DecimalNumberPrinter : NumberPrinter {
void Print(void) { cout << std::dec << i++ << endl; }
};

struct HexadecimalNumberPrinter : NumberPrinter {
void Print(void) { cout << "0x" << std::hex << i++ << endl; }
};

auto main(void) -> int
{
NumberPrinter *p;

cout << "Enter 1 for Decimal, or 2 for Hexadecimal: " << std::flush;

unsigned choice;
cin >> choice;

if ( 2 == choice )
p = new HexadecimalNumberPrinter;
else
p = new DecimalNumberPrinter;

p->i = 0;

for ( ; /* ever */ ; )
{
p->Print();

std::this_thread::sleep_for(std::chrono::milliseconds(100u));

if ( Check_For_Interrupt() )
Interrupt_Routine();
}
}

bool Check_For_Interrupt(void)
{
// Interrupt after every 5 iterations

static unsigned i = 0u;

if ( i++ > 3u )
{
i = 0u;
return true;
}

return false;
}

struct VTable {
void (*funcptr[1u])(void);
};

#include <cstdint> // uintptr_t, uint32_t, uint64_t

#ifdef _WIN32

extern "C" int VirtualProtect(
uint64_t lpAddress,
uint64_t dwSize,
uint32_t flNewProtect,
uint32_t *lpflOldProtect
);

struct SYSTEM_INFO {
char stuff[4];
uint32_t dwPageSize;
char more_stuff[128];
};

extern "C" void GetSystemInfo(
uint64_t lpSystemInfo
);

#else

extern "C" uint32_t sysconf(int32_t name);
extern "C" int32_t mprotect(uint64_t addr, uint64_t len, int32_t prot);

#endif

void Set_Writeability_Of_Memory(void (**const p)(void), bool const writeable)
{
union {
void *p_start_of_page;
std::uintptr_t i_start_of_page;
};

p_start_of_page = p;

static std::uintptr_t page_size;

#ifdef _WIN32

SYSTEM_INFO sysinfo;
GetSystemInfo((uint64_t)&sysinfo);
page_size = sysinfo.dwPageSize;

//cout << "Page size == " << page_size << endl;

i_start_of_page -= (i_start_of_page % page_size);

uint32_t old_perms;
VirtualProtect(i_start_of_page, page_size, 0x04 /*PAGE_READWRITE*/, &old_perms);

#else

// Linux

page_size = sysconf( 30 /*_SC_PAGE_SIZE*/);
i_start_of_page -= (i_start_of_page % page_size);
mprotect(i_start_of_page, page_size, 1 /*PROT_READ*/ | (writeable ? 2 /*PROT_WRITE*/ : 0u));

#endif
}

bool Try_Replace_Entry_In_VTable(VTable *const pvtable, void (*const before)(void), void (*const after)(void))
{
unsigned const how_many_pointers_to_try = 5u;

for (unsigned i = 0; i != how_many_pointers_to_try; ++i)
{
if ( before == pvtable->funcptr[i] )
{
Set_Writeability_Of_Memory(&pvtable->funcptr[i], true);
pvtable->funcptr[i] = after;
Set_Writeability_Of_Memory(&pvtable->funcptr[i], false);
return true;
}
}

return false;
}

void Interrupt_Routine(void)
{
static bool alternator = false;

// cout << "Entered Interrupt Routine" << endl;

alternator = !alternator;

DecimalNumberPrinter obj;

#ifdef _WIN32

// This bit is complicated with the MS-Visual compiler

void (DecimalNumberPrinter::* mfp_address_of_decimal_func)(void) = &DecimalNumberPrinter::Print;
void (HexadecimalNumberPrinter::* mfp_address_of_hexadecimal_func)(void) = &HexadecimalNumberPrinter::Print;

char *addr_dec_alpha = (char*)&(void*&)mfp_address_of_decimal_func;
char *addr_hex_alpha = (char*)&(void*&)mfp_address_of_hexadecimal_func;

char *addr_dec_beta = (char*)(void*&)mfp_address_of_decimal_func;
char *addr_hex_beta = (char*)(void*&)mfp_address_of_hexadecimal_func;

addr_dec_beta += 0x14;
addr_hex_beta += 0x14;

addr_hex_beta += addr_hex_alpha - addr_dec_alpha + (11u * 8u);

void (*address_of_decimal_func)(void) = (void (*)(void))addr_dec_beta;
void (*address_of_hexadecimal_func)(void) = (void (*)(void))addr_hex_beta;

#else

// This bit is easy with the GNU compiler

void (*const address_of_decimal_func)(void) = reinterpret_cast<void(*)(void)>(&DecimalNumberPrinter::Print);
void (*const address_of_hexadecimal_func)(void) = reinterpret_cast<void(*)(void)>(&HexadecimalNumberPrinter::Print);

#endif

void (*const before)(void) = (alternator ? address_of_decimal_func : address_of_hexadecimal_func);
void (*const after)(void) = (alternator ? address_of_hexadecimal_func : address_of_decimal_func);

//cout << "Before: " << before << endl;
//cout << "After: " << after << endl;

if ( before == after )
cout << "Why the hell are these equal?" << endl; // I needed this when debugging MS-Visual and clang

// - - - - - - - - Pointer to vtable might be at the beginning of the object

if ( sizeof(obj) < sizeof(void(*)(void)) )
return;

VTable *pvtable = reinterpret_cast<VTable*>( *reinterpret_cast<void**>(&obj) );

if ( Try_Replace_Entry_In_VTable(pvtable, before, after) )
{
return;
}

// - - - - - - - - Or let's try at the end

if ( sizeof(obj) < sizeof(void(*)(void))+1u )
return;

pvtable = reinterpret_cast<VTable*>( reinterpret_cast<char*>(&obj + 1u) - sizeof(void*) );

// Watch out for unalligned memory access in the next line
if ( Try_Replace_Entry_In_VTable(pvtable, before, after) )
{
return;
}

// - - - - - - - - Or at the end but before the final padding bytes

if ( sizeof(obj) < sizeof(void(*)(void))+2u )
return;

for (unsigned i = 0; i != (sizeof(obj) - 1u - sizeof(void(*)(void))); ++i)
{
pvtable = reinterpret_cast<VTable*>( reinterpret_cast<char*>(pvtable) - 1u );

// Watch out for unalligned memory access in the next line
if ( Try_Replace_Entry_In_VTable(pvtable, before, after) )
{
return;
}
}
}

Frederick Gotham

unread,
Jun 11, 2020, 10:05:45 AM6/11/20
to
On Thursday, June 11, 2020 at 3:00:43 PM UTC+1, Frederick Gotham wrote:

> p = new DecimalNumberPrinter;https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible


I must have hit Ctrl + V somewhere by accident

David Brown

unread,
Jun 11, 2020, 10:20:19 AM6/11/20
to
On 11/06/2020 16:00, Frederick Gotham wrote:
> I started a thread here three days ago about altering the VTable of a class at runtime:
>

Just one question - why?

From the other thread, it was clearly established that this was kind of
messing around with vtable pointers was highly non-portable, unlikely to
work consistently with different flag combinations even for the
compilers you have tried, and not guaranteed to be reliable or
consistent even for different code or different classes, never mind
different compilers, versions, flags, or targets. And it would be less
efficient than using defined behaviour (such as a class static variable
and conditionals or function pointers).

I believe you indicated before that you were trying to fix some existing
code for which you only had the generated binary, not the source. In
those circumstances, hacks and cracks may be the only possibility. But
here you are trying to write it as source code - is it only as a way to
investigate and learn about vtables and virtual method implementations,
or do you plan to use it for something?






Frederick Gotham

unread,
Jun 11, 2020, 10:28:01 AM6/11/20
to
On Thursday, June 11, 2020 at 3:20:19 PM UTC+1, David Brown wrote:

> I believe you indicated before that you were trying to fix some existing
> code for which you only had the generated binary, not the source. In
> those circumstances, hacks and cracks may be the only possibility. But
> here you are trying to write it as source code - is it only as a way to
> investigate and learn about vtables and virtual method implementations,
> or do you plan to use it for something?


I'm playing around with this C++ sample program to gain a good understanding of how this all works, before I try to alter the machine code of the other program. I'm also finding it very interesting and very exercising for my intellect.

If I spend two weeks on this by myself, then that's about 80 man-hours of work. Rewriting the program would, I think, be at least a thousand man-hours.

The saving grace about all this is that it will either work, or not work. Once I've got it working I don't have to worry about something breaking three months from now on a customer site.

Manfred

unread,
Jun 11, 2020, 11:52:49 AM6/11/20
to
On 6/11/2020 4:27 PM, Frederick Gotham wrote:
> On Thursday, June 11, 2020 at 3:20:19 PM UTC+1, David Brown wrote:
>
>> I believe you indicated before that you were trying to fix some existing
>> code for which you only had the generated binary, not the source. In
>> those circumstances, hacks and cracks may be the only possibility. But
>> here you are trying to write it as source code - is it only as a way to
>> investigate and learn about vtables and virtual method implementations,
>> or do you plan to use it for something?
>

I've got no idea if this is for anything serious, however:

>
> I'm playing around with this C++ sample program to gain a good understanding of how this all works, before I try to alter the machine code of the other program. I'm also finding it very interesting and very exercising for my intellect.

Many have made clear that this messing around with vtable leads
definitely to undefined behavior in C++, so all the understanding you
may get is about how an instance of a compiler may convert some instance
of source code into some instance of machine code - you don't get
general or reliable understanding on how C++ works.

>
> If I spend two weeks on this by myself, then that's about 80 man-hours of work. Rewriting the program would, I think, be at least a thousand man-hours.

Keep in mind that these 80 hours will not give you a reliable solution.

>
> The saving grace about all this is that it will either work, or not work. Once I've got it working I don't have to worry about something breaking three months from now on a customer site.
>

That's the most serious part: there is no saving grace about all this:
it will not either work or not work - you will end up with something
that works while you are playing with it, but will give no guarantee to
work after it is deployed, when you actually need it to work.
As others have said, trial and error does not work in this case (and
mostly not with C++ in general)

The Real Non Homosexual

unread,
Jun 11, 2020, 11:55:47 AM6/11/20
to
You're a fucking moron that needs to be banned from using a computer.

The Real Non Homosexual

unread,
Jun 11, 2020, 12:10:55 PM6/11/20
to
And the only intellect that you appear to have is operating a cash register.

Manfred

unread,
Jun 11, 2020, 12:23:39 PM6/11/20
to
On 6/11/2020 5:55 PM, The Real Non Homosexual wrote:
> You're a fucking moron that needs to be banned from using a computer.
>

*PLONK*

Scott Newman

unread,
Jun 11, 2020, 12:29:04 PM6/11/20
to
> Keep in mind that these 80 hours will not give you a reliable solution.

As I've shown with my great code there are ways to modify the vtable
reliably.

James Kuyper

unread,
Jun 11, 2020, 1:05:07 PM6/11/20
to
Your code is intended to change the vtable pointer, while his code is
intended to change one of the entries in the vtable, a very different thing.

As others have shown, on many implementations you way doesn't actually
work. In fact, so far no one has reported being able to duplicate your
accomplishment on any computer other than your own, even when using the
same compiler and source code.

Scott Newman

unread,
Jun 11, 2020, 1:10:49 PM6/11/20
to
> Your code is intended to change the vtable pointer, while his code is
> intended to change one of the entries in the vtable, a very different
> thing.

If you modify it for any object that's the same. Modifying the vtable
itself isn't reliable because it might reside in read-only pages.

> As others have shown, on many implementations you way doesn't actually
> work.

No, i wrote perfect code.

James Kuyper

unread,
Jun 11, 2020, 1:52:11 PM6/11/20
to
On 6/11/20 1:10 PM, Scott Newman wrote:
>> Your code is intended to change the vtable pointer, while his code is
>> intended to change one of the entries in the vtable, a very different
>> thing.
>
> If you modify it for any object that's the same. ...

No - his approach, it if works, affects the behavior of all objects of
the same type. Your approach, if it worked, would only affect the
behavior of a single object. That's not the same.

> ... Modifying the vtable
> itself isn't reliable because it might reside in read-only pages.

True - which is one of the problems with his code. But the reason why
implementations are allowed to put the vtable in read-only pages is the
same as the reason why they're allowed, for instance, to put the vtable
pointer in a different location than your expects it to be in: you both
have written code with undefined behavior.

>> As others have shown, on many implementations you way doesn't actually
>> work.
>
> No, i wrote perfect code.

Your "perfect" code produces behavior different from the intended
behavior when compiled by other people, and you can confirm that fact
yourself using godbolt. We have only your own word for it that it works
for you - and your word isn't worth much.

Frederick Gotham

unread,
Jun 11, 2020, 2:02:57 PM6/11/20
to
James wrote:
> Your code is intended to change the vtable pointer,
> while his code is intended to change one of the
> entries in the vtable, a very different thing.


See this is why I started a new thread -- because everybody was talking about changing an individual object. I want to make a change that affects not just one object, but every object of the class.


Scott wrote:
> Modifying the vtable itself isn't reliable
> because it might reside in read-only pages.


This is why I do 'memprotect' on Linux and 'VirtualProtect' on MS-Windows. My code fails on both systems if I don't first make the page writable.

red floyd

unread,
Jun 11, 2020, 2:38:06 PM6/11/20
to
Not to mention that there is no requirement that there actually BE a
vtable. Yes, most -- if not all -- implementations use one, but I can
imagine an implementation that, for example, keeps pointers to the
virtual functions in the class object itself, rather than in a vtable.

Also, I can imagine an implementation choosing to put the vtable in the
code space, specifically to avoid someone trying to play around with it.
In such an implementation, any attempt to modify the vtable would most
likely segfault, or create an access violation.

David Brown

unread,
Jun 11, 2020, 2:41:08 PM6/11/20
to
On 11/06/2020 20:02, Frederick Gotham wrote:
> James wrote:
>> Your code is intended to change the vtable pointer, while his code
>> is intended to change one of the entries in the vtable, a very
>> different thing.
>
>
> See this is why I started a new thread -- because everybody was
> talking about changing an individual object. I want to make a change
> that affects not just one object, but every object of the class.
>

Unfortunately, in Usenet you have no control over who posts, or what
they post. You only have control over who you listen to. I hope you
have enough sense to ignore Scot's posts, and pay attention to those of
reliable contributors who know about C++, such as James, Manfred, Keith,
and others.

The Real Non Homosexual

unread,
Jun 11, 2020, 2:44:39 PM6/11/20
to
Remind me to NEVER hire the op for anything beyond pushing a broom.

David Brown

unread,
Jun 11, 2020, 2:45:10 PM6/11/20
to
Just be very clear that while you might gain an understanding of how the
original machine code program works, you won't learn anything more about
C++ and won't learn anything reliable about your compiler. The machine
code you have got is fixed (for now) - if you figured out where a
virtual method pointer is, you know it will stay there because it is not
being compiled again. But you learn nothing reliable about where a
compiler might put a virtual method pointer in a different run of a
compiler - this is /not/ fixed, and not something you can build upon.

As long as you understand the difference, and understand that you are
gaining ideas about how to hack the old program and not learning
techniques for the future, you'll be fine.

Frederick Gotham

unread,
Jun 11, 2020, 3:41:21 PM6/11/20
to
Manfred wrote:

> - you don't get general or reliable
> understanding on how C++ works.


I believe that you can learn more about fixing car gear boxes by learning how to maintain a cold water aquarium. Knowledge and experience is very transferable in the human mind. Knowing how any given compiler turns C++ code into machine code can only be of benefit.


> Keep in mind that these 80 hours
> will not give you a reliable solution.


There are times in life when a solution either very evidently works, or very evidently doesn't work, with little to no grey are in between. Like if you want to propel a space rocket from Earth, it either makes it into space or it falls back down.

The program I'm working on will either work or not work, and so I'll be certain when I have it.


> That's the most serious part: there is no saving grace about all this:
> it will not either work or not work - you will end up with something
> that works while you are playing with it, but will give no guarantee
> to work after it is deployed, when you actually need it to work.
> As others have said, trial and error does not work in this case (and
> mostly not with C++ in general)


My boss sends me an email once a week containing feedback from about half a dozen customer sites. Every time we release a new firmware, it is first tested in-house for about a week constantly, before it goes out to customers, and so we get feedback from the in-house test as well as the customers. So if I write some crazy code then we'll know within a month if it's successful in the real world.

If you're having a canary about this then you really don't wanna see the stuff I've done in the past. Management typically gives me all the tasks that everyone else has given up on. I was working on Barebox trying to get an ACPI watchdog timer working, and I ended up having to transfer hex values for the ACPI table between two different platforms (converting endianness too).

I'm like the Emergency Room surgeon in my job. There's the normal surgeons who do a nice job by the book, and then there's me: The guy who has to work with the scraps he's given and try to make something work.

Frederick Gotham

unread,
Jun 11, 2020, 3:53:57 PM6/11/20
to
Red wrote:
> Not to mention that there is no requirement
> that there actually BE a vtable.

David wrote:
> As long as you understand the difference,
> and understand that you are gaining ideas
> about how to hack the old program and not
> learning techniques for the future, you'll be fine.


Red and David, I think the both of you are thinking more like doctors than vetenarians. A doctor pretty much always knows exactly what to expect: two lungs and a heart at the top, a liver and two kidneys at the bottom. A veteranian has to keep on their toes, to expect the unexpected, to be inventive and creative, and to get the job done no matter what's carried in through the door.

I realise that some programmers throw around phrases like "daemons fly out your nose", but in reality there is only a finite amount of ways to do something and a finite amount of outcomes. I do agree that there's more than one way to skin a cat but I don't believe that there's more than half a dozen good ways.

I think that some programmers live in a dream world where there's infinite ways of doing infinite things.

David Brown

unread,
Jun 11, 2020, 4:06:56 PM6/11/20
to
On 11/06/2020 21:53, Frederick Gotham wrote:
> Red wrote:
>> Not to mention that there is no requirement
>> that there actually BE a vtable.
>
> David wrote:
>> As long as you understand the difference,
>> and understand that you are gaining ideas
>> about how to hack the old program and not
>> learning techniques for the future, you'll be fine.
>
>
> Red and David, I think the both of you are thinking more like doctors than vetenarians.

No - we are just trying to recommend you don't do something utterly
pointless, undefined, unreliable, inefficient, and worse in every way
than simple, clear alternatives. It's one thing to do something
reckless to hack an existing binary because you have no alternative -
but trying to this in C++ source code has no pros and a whole lot of cons.

If you really think messing around with entries in the vtable is a
viable and useful technique in C++ that works in practical cases, and
that is something you expect to use in your coding, then please say so
directly. Then I and others here will know that there is no point in
helping you or advising you.

Ian Collins

unread,
Jun 11, 2020, 4:43:45 PM6/11/20
to
Troll alert!

--
Ian.

The Real Non Homosexual

unread,
Jun 11, 2020, 4:44:09 PM6/11/20
to
Again, the OP needs to be banned from coming near a computer.

The Real Non Homosexual

unread,
Jun 11, 2020, 4:52:52 PM6/11/20
to
Pull my finger.

thomas.h...@gmail.com

unread,
Jun 11, 2020, 5:08:54 PM6/11/20
to
David wrote:

> If you really think messing around with
> entries in the vtable is a viable and
> useful technique in C++ that works in
> practical cases, and that is something
> you expect to use in your coding, then
> please say so directly.


If I had the C++ code I'd probably use a global function pointer whose value I would change when I want to choose which function should be invoked. Or maybe even just a boolean:

void SomeClass::SomeFunc(void)
{
if ( global_boolean )
this->Func1();
else
this->Func2();
}

Not sure why a person would think vtable alteration would be my first choice.

Although with that said, if I knew which compiler I was using and if I knew it wouldn't change, then there's no harm in doing strange things if you know they'll always work (e.g. no caching of vtable). Of course this would be at the expense of portability to other compilers (and the C++ standards).

Frederick Gotham

unread,
Jun 11, 2020, 5:15:24 PM6/11/20
to
On Thursday, June 11, 2020 at 11:08:54 PM UTC+2, thomas....@gmail.com wrote:

> If I had the C++ code I'd probably use a global function pointer whose value I would change when I want to choose which function should be invoked. Or maybe even just a boolean:
>
> void SomeClass::SomeFunc(void)
> {
> if ( global_boolean )
> this->Func1();
> else
> this->Func2();
> }
>
> Not sure why a person would think vtable alteration would be my first choice.
>
> Although with that said, if I knew which compiler I was using and if I knew it wouldn't change, then there's no harm in doing strange things if you know they'll always work (e.g. no caching of vtable). Of course this would be at the expense of portability to other compilers (and the C++ standards).



That was me, Frederick, who posted that last message. I think I had another account logged in simultaneously on Google Groups.

Frederick

Chris M. Thomasson

unread,
Jun 11, 2020, 5:59:56 PM6/11/20
to

Melzzzzz

unread,
Jun 11, 2020, 10:11:23 PM6/11/20
to
Better implement M$ COM interface in C... you will be portable and
understand how vtables work.


--
current job title: senior software engineer
skills: c++,c,rust,go,nim,haskell...

press any key to continue or any other to quit...
U ničemu ja ne uživam kao u svom statusu INVALIDA -- Zli Zec
Svi smo svedoci - oko 3 godine intenzivne propagande je dovoljno da jedan narod poludi -- Zli Zec
Na divljem zapadu i nije bilo tako puno nasilja, upravo zato jer su svi
bili naoruzani. -- Mladen Gogala

Scott Newman

unread,
Jun 12, 2020, 3:07:25 AM6/12/20
to
>> If you modify it for any object that's the same. ...

> No - ...

If you modify it for any object that's the same.

> Your "perfect" code produces behavior different from the intended
> behavior when compiled by other people, ...

No, the code was perfect on any machine.

0 new messages