These days it's not _that_ straightforward with thing like ASLR, NX and heap hardening. You need some sort of information leak for ASLR, then somehow start controlling R/EIP (which may prove difficult if there isn't interesting things on the heap nearby), write a ROP chain, and then pivot into something more useful (if you can't/don't want to ROP your way to a shell).
A segfault only happens when you try to access a virtual memory address not mapped to your process, or try to write to a page mapped to you but not mapped as writable.
Remember, this is C. There's nothing to stop you from writing past the end of some memory like an array as long as whatever memory you're writing into still belongs to you. If you write far enough, you'll eventually walk out of your mapped memory pages and trigger a segfault, but if you don't stray toooo far, you can overwrite some important data and absolutely nothing will complain. When people say C isn't a safe language, they mean it. C will let you get away with murder.
And it turns out that there's some very important data you can overwrite this way.
> How would you get a bash shell?
Let's say there's a function foo() that writes some data to a buffer on foo's stack, and I can control what data it writes (because it's data from a text box on a webpage, let's say). And foo() is buggy and doesn't validate that in all cases the data I control fits in the bounds of the buffer it writes to.
I can then overflow the buffer with my data, and take advantage of that to overwrite the return address of foo(), because the return address for the function happens to exist past the end of all the stack local memory for the function. When foo() returns, it will jump to the address I wrote in there, instead of where it was supposed to go back to. And as long as that address is in the process's mapped memory pages, again, nothing will complain.
To get a shell, as part of the overflow I either insert the binary data corresponding to the x86 instructions for something like a call to execve("/bin/sh", ...) and then have foo()'s return jump to the beginning of my instructions, or I cause the return to jump to some other code or library that will do that for me that happens to already be in place (there are more sophisticated versions of this exploit called Return Oriented Programming).
If want to read up on this: https://www.win.tue.nl/~aeb/linux/hh/hh-10.html