Click here to Skip to main content
14,302,993 members

Dark corners and pitfalls of C++

Rate this:
5.00 (12 votes)
Please Sign up or sign in to vote.
5.00 (12 votes)
15 Aug 2019GPL3
C++: love and intrigue

Introduction

C++ is a very powerful and versatile tool, but you have to pay for this.

As Bjarne Stroustrup once said:

"C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off".

That article would teach you how to completely shoot off all your legs (arms, heads and other parts) with the most interesting, unpredictable and exciting ways you can imagine!

Background

In this article, we want to show how it is important to understand the aspects of writing a stable, safe and reliable code and how really easy it is to unintentionally inject vulnerability in it. We hope that would be both interesting and useful to you.

To the code!

Here is a short snippet of some abstract C++ code. As you can see, that is a code from the Windows DLL (and that point is really important!). Assume that someone is expecting to use that code in some (secure, of course!) solution.

Take time looking at it. Who knows what you can find here? And what in this code could possibly go wrong?

// Singleton
class Finalizer
{
    struct Data
    {
        int i = 0;
        char* c = nullptr;
        
        union U
        {
            long double d;
            
            int i[sizeof(d) / sizeof(int)];
            
            char c [sizeof(i)];
        } u = {};
        
        time_t time;
    };
    
    struct DataNew;
    DataNew* data2 = nullptr;
    
    typedef DataNew* (*SpawnDataNewFunc)();
    SpawnDataNewFunc spawnDataNewFunc = nullptr;
    
    typedef Data* (*Func)();
    Func func = nullptr;
    
    Finalizer()
    {
        func = GetProcAddress(OTHER_LIB, "func")
        
        auto data = func();
        
        auto str = data->c;
        
        memset(str, 0, sizeof(str));
        
        data->u.d = 123456.789;
        
        const int i0 = data->u.i[sizeof(long double) - 1U];
        
        spawnDataNewFunc = GetProcAddress(OTHER_LIB, "SpawnDataNewFunc")
        data2 = spawnDataNewFunc();
    }
    
    ~Finalizer()
    {
        auto data = func();
        
        delete[] data2;
    }
};

Finalizer FINALIZER;

HMODULE OTHER_LIB;
std::vector<int>* INTEGERS;

DWORD WINAPI Init(LPVOID lpParam)
{
    OleInitialize(nullptr);
    
    ExitThread(0U);
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
    static std::vector<std::thread::id> THREADS;
    
    switch (fdwReason)
    {
        case DLL_PROCESS_ATTACH:
            CoInitializeEx(nullptr, COINIT_MULTITHREADED);
            
            srand(time(nullptr));
            
            OTHER_LIB = LoadLibrary("B.dll");
            
            if (OTHER_LIB = nullptr)
                return FALSE;
            
            CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);
        break;
        
        case DLL_PROCESS_DETACH:
            CoUninitialize();
            
            OleUninitialize();
            {
                free(INTEGERS);
                
                const BOOL result = FreeLibrary(OTHER_LIB);
                
                if (!result)
                    throw new std::runtime_error("Required module was not loaded");
                
                return result;
            }
        break;
        
        case DLL_THREAD_ATTACH:
            THREADS.push_back(std::this_thread::get_id());
        break;
        
        case DLL_THREAD_DETACH:
            THREADS.pop_back();
        break;
    }
    return TRUE;
}

__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()
{
    for (int i : integers)
        i *= c;
    
    INTEGERS = new std::vector<int>(integers);
}

int Random()
{
    return rand() + rand();
}

__declspec(dllexport) long long int __cdecl _GetInt(int a)
{
    return 100 / a <= 0 ? a : a + 1 + Random();
}

Do you find this code quite simple, obvious, absolutely safe and hassle-free? Or maybe you found some problems here? Or maybe you even found a dozen or two?

Well, actually there are more than 43 (yep, forty-three!) potential threats of varying degrees of significance in this code chunk.

Points of Interest

  • 1) The sizeof(d) (where d is a long double) is not necessarily multiple of the sizeof(int)
int i[sizeof(d) / sizeof(int)];

Such a situation is not checked nor handled here. For example, the size of a long double could be 10 on some platforms (which is not true for MS VS compiler, but true for a RAD studio, former C++ Builder).

int can also be of different sizes depending on the platform (well, the code above is for Windows, so, applied specifically to that current situation, the problem is somewhat contrived, but for the portable code, the problem arises).

https://www.viva64.com/en/t/0012

(See https://www.viva64.com/en/t/0012)

All that would become a problem if we want a type punning here. By the way, a type punning causes an undefined behaviour due to the C++ language standard (yet still that is a common practice, because modern compilers usually do define a correct, expected behaviour for that, like a GCC, for example).

By the way, in modern C the type punning is perfectly allowed (you do understand that C and C++ are different languages and that you should not expect to know C if you know C++ and vice verse, do you?)

The solution: use static_assert to control all that kind of assumptions at the compile time. That would warn you if something with the type's sizes goes wrong:

static_assert(0U == (sizeof(d) % sizeof(int)),
              "Size of the bigger type is not multiple of sizes of the smaller type");
  • 2) time_t is a macro, in Visual Studio it can refer to 32 (old) or 64 bit (new) integer type
time_t time;

Accessing that can cause out of border reads/writes or type slicing (corrupting the memory or resulting in reading garbage) if two different binary modules (for example, an executable and a DLL, which it loads) are compiled with the different physical representation of that type.

The solution: ensure the same strictly sized types are used to share the data between all communicating modules:

int64_t time;
  • 3) B.dll (which should be referred by the OTHER_LIB handle) is not yet loaded at this point, so we will fail to attempt to get an address from it
  • 4) static initialization order fiasco (OTHER_LIB object is used, while it is not yet initialized and contains garbage)
func = GetProcAddress(OTHER_LIB, "func");

FINALIZER is a static object, which is constructed before a call to the DllMain. So in its constructor, we are attempting to use the library, which is loaded later. And the problem worsens because OTHER_LIB static object which is used by the FINALIZER static object is defined later than it in the translation unit, which means it would be initialized (zeroed) later. That means it will simply contain some pseudo-random garbage. Gladly WinAPI should handle that correctly, because with the high probability there will be no module loaded with such handle value, and even if it does exist - it would probably lack the "func" function in it (but if it eventually does, oh boy...)

The solution: the general hint is to avoid using global objects at all, especially complicated ones, especially if they are depending on each other, especially in the DLL. However, if you still need them for some reason, be very careful with their initialization order. To control that order, place all global objects instances (definitions) in the one translation unit in the correct order, to ensure they are initialized properly.

  • 5) the previously returned result is not checked before use
auto data = func();

func is a pointer to the function. It should point to the function from the B.dll. But, because we completely failed all the things in the previous step, it will be nullptr. So attempting to dereference it will lead to something interesting and fascinating like access violation or general protection fault etc.

The solution: when dealing with the external code (WinAPI in our case), always check the return result of the provided functions. For reliable and fail-safe systems this rule is still useful even if there are strict contract exists for those functions.

  • 6) garbage if compiling with the different alignment/padding settings
auto str = data->c;

If Data struct (which is used to share information between the communicating modules) has different physical representation through the binary modules, we will end up in the previously mentioned access violation, general protection faultsegmentation-faultheap corruption etc. Or we will read garbage. An exact outcome depends on the actual scenario of using that memory. All of that could happen because the struct itself lacks an explicit alignment/padding settings, so in case if those global settings were different for those communicating modules when they were compiled, we run into trouble.

The solution: ensure all shared data structures have strict, explicitly defined and obvious physical representation (fixed-size types, alignment definition, etc) and/or communicating binaries are compiled with the same alignment/padding settings.

See also:

Alignment (C++ Declarations)

Data structure alignment

Struct padding in C++

  • 7) using the size of the pointer instead of the size of an array, which it is pointed
memset(str, 0, sizeof(str));

That is usually a typo. But things can be complicated when dealing with the static polymorphism or using auto keyword (especially when it is overused). I really hope modern compilers are already smart enough to detect such problems during the compilation phase using its internal static code analysis capabilities.

The solution:

- never confuse sizeof(<full object type>) and sizeof(<object's pointer type>)

- do not blindly ignore the compiler warnings

- you can even use a bit of the C++ template magic by combining typeid, constexpr and static_assert to ensure the correctness of types at compile stage (also type traits can be useful here, like std::is_pointer for example)

const int i0 = data->u.i[sizeof(long double) - 1U];

Well, that was already mentioned earlier, so here we just got another point of presence of a previously discussed problems.

The solution: do not access another field, then one which was previously set, unless you are pretty sure your compiler handles that correctly. Ensure sizes of types of shared objects is the same in all communicating modules.

See also:

Type-punning and strict-aliasing

What is the Strict Aliasing Rule and Why do we care?

  • 10) even if the B.dll was correctly loaded and "func" function is correctly exported and located, B.dll is anyway unloaded at this point (because of the FreeLibrary call in a DllMain/DLL_PROCESS_DETACH callback section), so we will get a crash here
auto data = func();

Possibly, calling member function using the destroyed polymorphic object or calling the function from an unloaded dynamic library will lead to the pure virtual function call.

The solution: implement correct finalization routine in the application, ensuring all dynamic libraries finish their work and unloaded in the proper order. Avoid using static objects with complicated logic in the DLL. Avoid performing actions after the DLL finally exits its entry point (and starting to destroy the static objects).

Understand the DLL life cycle:

    ... other module calls LoadLibrary ...

1) construction of the library static objects (should contain only very simple logic, called automatically)

2) DllMain -> DLL_PROCESS_ATTACH callback event (should contain only very simple logic, called automatically)

[!!] From now other threads of the application can start calling

DllMain -> DLL_THREAD_ATTACH/DLL_THREAD_DETACH in parallel (called automatically, see notes on p. 30)

Those sections can possibly contain some complicated logic (like per thread random seeding) but still beware

3) custom initialization routine (exported by the DLL developer) is called

(contains all the heavy initialization work, should be manually called by one, who is loading your library)

[your library can create its own threads now and later]

[..] the library performs its main work

4) custom deinitialization routine (exported by the DLL developer) is called

(contains all the heavy finalization work, should be manually called by one, who loaded your library)

[after this point avoid performing any actions in your library, all previously started library threads should be finished before returning from that function]

    ... other module calls FreeLibrary ...

5) DllMain -> DLL_PROCESS_DETACH (should contain only very simple logic, called automatically)

6) destruction of the library static objects (should contain only very simple logic, called automatically)

  • 14) UB if allocated using new and deleting using delete[]
delete[] data2;

In general, you should always be cautious when freeing and deleting objects received from the external modules.

Also, it is a good practice to nullify the pointers on the deleted objects.

The solution is to ensure that:

  • when an object is being deleted its full type (which is pointed by the pointer we deleting by) is known
  • all destructors have a body
  • the library from which any code is exported is not unloaded too early
  • correct form of new and delete always used
  • the pointer pointing to the deleted object(s) is nullified

Additionally note that:

calling the delete operator for a void pointer will cause undefined behaviour

pure virtual functions must not be called from the constructor

- call to a virtual function in the constructor is not virtual

- prefer to avoid manual memory management (use containersmove semantics and smart pointers instead)

See also:

Heap corruption: What could the cause be?

  • 15) ExitThread is the preferred method of exiting a thread in C code. In the C++ code, the thread is exited before any destructors can be called or any other automatic cleanup can be performed, so you should return from your thread function
ExitThread(0U);

The solution: never use this function manually in the C++ code, but rather just exit normally (by return statement) from the thread function.

  • 16) calling functions that require DLLs other than Kernel32.dll may result in problems that are difficult to diagnose. Calling User, Shell, and COM functions can cause access violation errors because some functions load other system components
CoInitializeEx(nullptr, COINIT_MULTITHREADED);

The solution - in the DllMain entry point:

  • avoid any complicated (de)initialization
  • avoid calling functions from the other libraries (or at least be very careful)
srand(time(nullptr));

The solution: MS VS requires that seed should be initialized per each thread. Also, using Unix time as a seed gives not enough randomness, prefer to use more advanced seed generation.

See also:

Is there an alternative to using time to seed a random number generation?

C++ seeding surprises

Getting random numbers in a thread-safe way [C#]

  • 19) can cause a deadlock or a crash (or create dependency loops in the DLL load order)
OTHER_LIB = LoadLibrary("B.dll");

The solution:

Do not use LoadLibrary in the DllMain entry point. Any complicated (de)initialization should be done in the specific exported functions like "Init" and "Deint". Your module provides those functions as a result of a contract established between importing and exporting modules. Both parties must strictly enforce the contract.

  • 20) misprint (condition is always false), incorrect program logic and possible resource leak (since OTHER_LIB is never unloaded if loaded successfully)
if (OTHER_LIB = nullptr)
    return FALSE;

Copy assignment operator returns left type reference i. e. if would check the value of OTHER_LIB (which will be nullptr) and nullptr will be interpreted as false.

The solution - always use reversed form to avoid such misprints:

if/while (<constant> == <variable/expression>)
  • 21) better use _beginthread (especially if linked to the static C run-time library) or you can get memory leaks in a call to the ExitThread, DisableThreadLibraryCalls
  • 22) DLL notifications are serialized, the entry-point function (DllMain) should not attempt to create or communicate with other threads or processes (deadlocks may occur)
CreateThread(nullptr, 0U, &Init, nullptr, 0U, nullptr);
  • 23) calling COM functions during termination can cause access violation errors because the corresponding component may already have been unloaded or uninitialized
CoUninitialize();
  • 24) there is no way to control the order in which in-process servers are loaded or unloaded, so do not call OleInitialize or OleUninitialize from the DllMain function
OleUninitialize();

See also:

COM Clients and Servers

In-process, Out-of-process, and Remote Servers

  • 25) calling free on memory block allocated with the new
  • 26) if the process is terminating (the lpvReserved parameter is non-NULL), all threads in the process except the current thread either have exited already or have been explicitly terminated by a call to the ExitProcess function, which might leave some process resources such as heaps in an inconsistent state, so it is not safe for the DLL to clean up the resources. Instead, the DLL should allow the operating system to reclaim the memory
free(INTEGERS);

The solution:

Ensure an old C style of dealing with the dynamic memory is not mixed with the modern C++ style. Be very careful when managing the resources in a DllMain entry point.

  • 27) can result in a DLL being used after the system has executed its termination code
const BOOL result = FreeLibrary(OTHER_LIB);

The solution: do not call FreeLibrary in the DllMain entry point.

  • 28) will crash current (possibly main) thread
throw new std::runtime_error("Required module was not loaded");

The solution - prefer not to throw exceptions in the DllMain entry point. If the DLL could not be loaded correctly for any reason, it should return FALSE. Throwing exceptions during the DLL_PROCESS_DETACH is not only a bad design approach (and almost meaningless) but also could possibly lead to the problems during the deinitialization stage.

In any case, always be very careful throwing exceptions outside of the DLL. Any complicated objects (like classes of the standard library) may have a different physical representation (and even logic) in some cases, for example, if two binary modules are compiled with the different (incompatible) versions of the runtime library.

Prefer to exchange only simple data types (with fixed sizes and determined representation) between modules.

Also, remember, that exiting or terminating the main thread will automatically terminate all the others (which would not have a chance to be finished correctly, so they can corrupt the memory, leaving mutexes, heaps and other objects in the unpredictable, inconsistent state, and also those threads would be already dead at the time when the static objects will start their own deconstruction, so do not attempt to wait for a threads here).

See also:

Top 20 C++ multithreading mistakes and how to avoid them

THREADS.push_back(std::this_thread::get_id());

Since DLL_THREAD_ATTACH section is invoked from some unknown external code, do not expect the correct behaviour here.

The solution: enclose with the try/catch those instructions, which could possibly throw exceptions that can't be expected to be handled correctly (especially if they go out of the DLL).

See also:

How can I handle a destructor that fails?

  • 30) UB if there were threads presented before this DLL was loaded
THREADS.pop_back();

Existing threads (including that one which is actually loading the DLL) do not call the entry-point function of the newly loaded DLL (so they are not registered in the THREADS vector during DLL_THREAD_ATTACH event), while they still call it with DLL_THREAD_DETACH on finishing.

Which means a consideration that a number of calls to the DLL_THREAD_ATTACH and DLL_THREAD_DETACH are always equal is wrong, those making any logic depending on it dangerous,

  • 32) passing complicated object between modules (can cause a crash if the are compiled with the different runtimes: release/debug, different versions etc)
  • 33) accessing object c by its virtual address (which is shared between modules) can cause problems, if pointers are threated differently in those modules (for example, if the modules are linked with the different [/LARGEADDRESSAWARE] options)
__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()

See also:

Is it possible to use more than 2 Gbytes of memory in a 32-bit program launched in the 64-bit Windows?

Application with LARGEADDRESSAWARE flag set getting less virtual memory

Drawbacks of using /LARGEADDRESSAWARE for 32 bit Windows executables?

how to check if exe is set as LARGEADDRESSAWARE [C#]

/LARGEADDRESSAWARE может испортить вам весь день [Ru]

ASLR (Address Space Layout Randomization) [Ru]

And also...

Virtual memory

Physical Address Extension

Tagged pointer

std::ptrdiff_t

What is uintptr_t data type

Pointer arithmetic

Pointer aliasing

What is the strict aliasing rule?

reinterpret_cast conversion

restrict type qualifier

And finally...

Wait, did I forgot something? Surely I do! :)

Because pointers are, in fact, much more complicated stuff than people usually think about them. I am pretty sure you can add something important in the comments (maybe something about the difference between pointer to object and a pointer to the function, that perhaps not all the bits in a pointer value can be used to form an address and so on).

for (int i : integers)

    i *= c;

Mistake: original items in the container would not change, need to use a reference (prefer to use two types of references: 1 and 2:)

INTEGERS = new std::vector<int>(integers);

however, that function's throw specification is empty:

__declspec(dllexport) int Initialize(std::vector<int> integers, int& c) throw()

std::unexpected is called by the C++ runtime when a dynamic exception specification is violated: an exception is thrown from a function whose exception specification forbids exceptions of this type.

The solution: use try/catch (especially when allocating resources, especially in the DLL) or use nothrow form. In any case, do not expect infinite resources.

See also:

RAII

We do not use C++ exceptions

Memory Limits for Windows and Windows Server Releases

Problem 1: forming such a "more random" value is incorrect. As the сentral limit theorem states, a sum of the independent random variables tends toward a normal distribution (even if the original variables themselves are not normally distributed).

Problem 2: possible integer overflow (which is UB for signed integers)

return rand() + rand();

When dealing with such things like randomization, encryption, etc beware of using some homemade "solutions". If you lacking a specific math education and knowledge, heavy experience with those concepts, chances are high that you will simply outsmart yourself, making things worse.

__declspec(dllexport) long long int __cdecl _GetInt(int a)

Multiple problems (and their possible solutions):

return 100 / a <= 0 ? a : a + 1 + Random();

See also:

Do not use std::rand() for generating pseudorandom numbers

And also...

ExitThread function

ExitProcess function

TerminateThread function

TerminateProcess function

That's not all! We have even more intrigue code for you ;)

Imagine you have some important content in memory (user password, for example). Surely you don't want to keep it in memory for a long time (increasing the probability someone could read it from here). 

A naive approach to achieve that would look like that:

bool login(char* const userNameBuf, const size_t userNameBufSize,
           char* const pwdBuf, const size_t pwdBufSize) throw()
{
    if (nullptr == userNameBuf || '\0' == *userNameBuf || nullptr == pwdBuf)
        return false;
    
    // Here some actual implementation, which does not checks params
    //  nor does it care of the 'userNameBuf' or 'pwdBuf' lifetime,
    //   while both of them obviously contains private information 
    const bool result = doLoginInternall(userNameBuf, pwdBuf);
    
    // We want to minimize the time this private information is stored within the memory
    memset(userNameBuf, 0, userNameBufSize);
    memset(pwdBuf, 0, pwdBufSize);
}

Well, that, of course, would not work. So, what to do then?

Wrong "solution" #1: if memset isn't working let's do that manually!

void clearMemory(char* const memBuf, const size_t memBufSize) throw()
{
    if (!memBuf || memBufSize < 1U)
        return;
    
    for (size_t idx = 0U; idx < memBufSize; ++idx)
        memBuf[idx] = '\0';
}

And there is no reason why the modern compiler can't optimize that.

Btw, the memset function would be compiler intrinsic if they are enabled. That changes nothing in the current context, just an interesting thing to know.

See also:

The as-if rule

Are there situations where this rule does not apply?

Copy elision

Atomics and optimization

Wrong "solution" #2: trying to "improve" the previous "solution" by playing with the volatile keyword

void clearMemory(volatile char* const volatile memBuf, const volatile size_t memBufSize) throw()
{
    if (!memBuf || memBufSize < 1U)
        return;
    
    for (volatile size_t idx = 0U; idx < memBufSize; ++idx)
        memBuf[idx] = '\0';
    
    *(volatile char*)memBuf = *(volatile char*)memBuf;
    // There is also possibility for someone to remove this "useless" code in the future
}

Would that work? Well, it might. Probably. For example, such an approach is used in the MS VS RtlSecureZeroMemory (you can check its actual implementation in the Windows SDK sources). However, this is heavily compiler-dependent.

See also:

volatile member functions

Wrong "solution" #3: try to use wrong OS API (like RtlZeroMemory) or even STL (like std::fill, std::for_each) instead of the CRT or homemade code

RtlZeroMemory(memBuf, memBufSize);

And there are even more possibly wrong solutions!

And, finally, how to really fix that?

  • 1) use a specific OS API function, like RtlSecureZeroMemory for Windows
  • 2) C11 function memset_s is also suitable for that purpose:
Quote:

Unlike memset, any call to the memset_s function shall be evaluated strictly according to the rules of the abstract machine.

Also, we can prevent the compiler from optimizing the code out by outputting (to the file, console or another stream) the variable value, but this way obviously is not very useful.

See also:

Safe clearing of private Data

To be continued...

That is, of course, is not a complete list of all the possible troubles you can encounter writing applications using C/C++.

There are also such wonderful things like livelocks, race conditions (for example, caused by incorrect implementations of a none-blocking algorithm, ABA problems, improperly changing multiple atomics at once, thread-unsafe reference counters, incorrect implementations of a double-checking lock pattern and so on), objects slicingloss of arithmetic precision (due to rounding or numerically unstable algorithms, for example, summation of many doubles without first sorting them), threads and GDI objectsvolatile vs atomic, incorrect using of an integer literals (603 vs 0603), time-of-check to time-of-use, lambdas which outlives their reference captured objects, incorrect printf-family functions formatters, incorrectly sharing data between two devices with the different endianness (for example, through the network), bitfield details, confusing C++ exceptions and SEH, performing incorrect stack allocations, disabling ASLR, possible backdoors in API, confusing sizeof vs _countof, not using correct memory locking (also not that suspend mode on laptops and some desktop computers will save a copy of the system's RAM to disk, some architecture surprises, regardless of memory locks), stack corruptions etc. etc. etc.

Want to add more? Share your own interesting materials in the comments!

Want to know more?

There are a bunch of some other helpful external links we wish to present you. You can refer to those materials to extend your knowledge far further. God bless those wonderful authors across the internet that bring us so many exciting articles to read!

Software security errors

Common weakness enumeration

Common types of software vulnerabilities

Vulnerability database

Vulnerability notes database

National vulnerability database

Coding standards

Application security verification standard

Guidelines for the use of the C++ language in critical systems

Secure programming HOWTO

32 OpenMP Traps For C++ Developers

A Collection of Examples of 64-bit Errors in Real Programs

P. S.

When this article was actually finished and ready to be published, browsing up the internet for additional information to add here, this amazing commentary was found:

Image 2

History

13 Aug 2019 - added more useful links:

Exploitations (video lections)

C/C++ Memory Corruption And Memory Leaks

20 issues of porting C++ code to the 64-bit platform

As a programmer, what do I need to worry about when moving to 64-bit windows?

C: The Dark Corners

Darkest corners of C++

Compatibility of C and C++

Expert C Programming: Deep C Secrets

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)

Share

About the Author

SimbirSoft
SimbirSoft
Russian Federation Russian Federation
IT company that cares

We offer IT-analysis and consulting, custom software development, mobile application development for businesses.
Group type: Organisation (No members)



Comments and Discussions

 
QuestionMixing C++ pitfall and dynamic linkage pitfall Pin
JMH_FR22-Aug-19 3:21
memberJMH_FR22-Aug-19 3:21 
QuestionNo need to examine the code - the flavour of the code writing says its all! Pin
Bob100019-Aug-19 2:59
professionalBob100019-Aug-19 2:59 
AnswerRe: No need to examine the code - the flavour of the code writing says its all! Pin
rjmoses19-Aug-19 8:20
professionalrjmoses19-Aug-19 8:20 
GeneralRe: No need to examine the code - the flavour of the code writing says its all! Pin
Bob100019-Aug-19 9:57
professionalBob100019-Aug-19 9:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 14 Aug 2019

Tagged as

Stats

12.4K views
148 downloads
15 bookmarked