IPC examples – signals and shared memory

There are numerous ways programs can talk to each other. Modern operating systems provide decent isolation for security reasons, but programs must talk to each other this way or another. Today, after 2 articles on AWS, I’ll return to more generic IT topics. I’m going to describe 2 IPC (which stands for Inter Process Communication) mechanisms – signals and shared memory.

What is IPC?

Before we begin, we need to find out, what is the IPC. Well, this term refers to numerous communication options programs have. Whenever you open your browser, some communication between 2 processes occur – your browser reads data sent by a server process. There are Remote Procedure Calls, which apparently are one of the favorite attack methods used by script kiddies scanning WordPress installations:

There are countless other methods, but today I’d like to focus on 2 specific ones: signal sending and shared memory.

Signals

Signals are way to send simple notification to a process. This notification does not contain much information apart from “X happened”, so we have to figure out what to do with this information ourselves. There are signals that can’t be ignored, like SIGKILL, which is handled directly by the operating system’s kernel and is responsible for forcefully shutting down a process. Another signal, SIGTERM, which informs a process “hey, I’m going to shut you down, so please finish your job”. There are signals like SIGWINCH, which says window size has changed, there are 2 user signals and many more. The full list of signals can be found running kill -l.

Talking of which, kill command is not used for killing a process, but for sending a signal to an application. By default of course it kills a process, but its usage is much wider. This is just another example of how weird sense of humor the Unix creators had.

Shared memory

The operating system isolates each process – it gives unique address space, where a process can write and read its data. Therefore in order to pass some information to the other process, we need to use some dedicated mechanism, for example shared memory. The idea is to create a segment, where more than 1 process can write to and read from. In this case we have the context of an event, but at the same time we don’t get notifications that something happened. Those processes can use code written in different languages and use some sort of mapping to understand each other. For instance we could make the Python code understand C++ messages by using techniques I talked about in the article on writing C modules.

How to implement shared memory and signals?

Now let’s move to the example to let us better understand the concepts.

First, let’s take a look at the structure we’re going to share.

typedef struct {
    int id;
    size_t size;
    char* text;
} s_message;

The s_message is a message with id, size of the buffer we want to send and the actual text.

int main(int argc, char **argv) {
    size_t message_size;
    s_message *message;
    int destination_pid;

    if(argc != 2){
        printf("# Error. Usage: ./a.out <message>");
        return 1;
    }

    message_size = strlen(argv[1]);
    printf("# Message size is: %lu\n", message_size);
    printf("# Creating message object.\n");
    message = malloc(sizeof(s_message));
    message->id = 1;
    message->size = message_size;
    message->text = strdup(argv[1]);

    printf("# Forking...\n");
    if((destination_pid = fork()) == 0){
        listener();
        return 0;
    } else {
        sleep(1);
        sender(message, destination_pid);
    }

    printf("\033[0m# All done. Freeing memory.\n");
    free(message->text);
    free(message);
    return 0;
}

In the main function we check what message has the user passed, then we fill in the structure, create a new process with fork() and finally send the message. After accomplishing this, we clean up and finish the code. I’ve decided to color the outputs a little bit to make finding out which process is responsible for what events a bit easier.

The Reader

void listener(){
    printf("\033[0;35m# Listening on PID=%d\n", getpid());
    printf("\033[0;35m# Registering signal handler for SIGUSR1\n");
    signal(SIGUSR1, handler);
    // semicolon at the end - this is an endless loop.
    while(1);
}

Now, let’s see what happens in the listener. This function registers a listener that reacts to receiving a SIGUSR1 signal and performs an infinite loop afterwards. This is fine, we’re going to shut down the process in the handler.

void handler(int sig){
    signal(SIGUSR1, handler); // Reset signal
    printf("\033[0;33m# PID=%d received signal %d\n", getpid(), sig);

    key_t shm_key = ftok("./shared_path", 1);
    int shm_id = shmget(shm_key, 1024, 0666 | IPC_CREAT);
    s_message *sh_m = (s_message*) shmat(shm_id, (void*) 0, 0);
    // shmdt(sh_m);
    // shmctl(shm_id, IPC_RMID, NULL);

    printf("\033[0;33m# Received: %s\n", sh_m->text);
    exit(0);
}

Finally some deep tech details. There’s a lot happening here, so follow me. The first thing is resetting the signal handler. If the handler was changed somewhere else, we can set it back in the handler. Next, we call the ftok(), which, as can be read in the manual:

The ftok() function attempts to create a unique key suitable for use with
the semget(2), and shmget(2) functions, given the path of an existing
file and a user-selectable id.

So, we create a key that we later pass to the shmget(), which consecutively, gives us an identifier to a shared memory. The last step is to actually attach the memory. We do it using shmat and casting the bytes to s_message structure. Let’s also take a look at the sender code.

The writer

void sender(s_message* m, int destination_pid){
    printf(
        "\033[0;36m# Writing message to shared memory a new message.\n\tid=%d\n\ttext:%s.\n",
        m->id,
        m->text
    );

    key_t shm_key = ftok("./shared_path", 1);
    int shm_id = shmget(shm_key, 1024, 0666 | IPC_CREAT);
    s_message *sh_m = (s_message*) shmat(shm_id, (void*) 0, 0);
    memcpy(sh_m, m, sizeof(s_message));
    shmdt(sh_m);

    kill(destination_pid, SIGUSR1);
}

Here we perform some of the exactly the same steps when passing the message. We kreate a key, identifier and we attach a memory segment. Afterwards, however, we copy message that resides under the *m pointer to the newly created *sm_m shared memory segment. Last, we detach the memory.

The Process

The last thing we have to do is to compile the code and run it. Let’s do it!

➜  signals git:(master) ✗ gcc main.c           
➜  signals git:(master) ✗ ./a.out "Hello, world"
# Message size is: 12
# Creating message object.
# Forking...
# Listening on PID=24535
# Registering signal handler for SIGUSR1
# Writing message to shared memory a new message.
        id=1
        text:Hello, world.
# All done. Freeing memory.
# PID=24535 received signal 30
# Received: Hello, world

Awesome. The listener has successfully registered, the writer has sent the message and it was fully understood on the other side. Yay!

Summary of Signals and Shared Memory

Can we do it easier? Yes. We might use Unix sockets that allow us to utilize sockets, but without the need to have network connection. It means that a listener wouldn’t need any loops, because it would only have a blocking connection. The fact that a message has arrived would be noticed without the second mechanism like signals. Generally, we could gain quite a lot, but it would be less fun 😉 If you liked the post, consider subscribing to the newsletter to stay in touch with me!

Additional resources on signals and shared memory:

See also: