Arrays & Strings · beginner · ~10 min
**What you will learn** - How `strcpy` copies a C string byte by byte, including the terminating `\0`. - Why `strcpy` is fundamentally unsafe: it never knows or checks the size of the destination buffer. - What a buffer overflow is, how it corrupts memory, and why it has caused thousands of real security bugs. - How to write a safe, bounded copy using `snprintf` (and how to use `strncpy` correctly when you must). - How to track and pass buffer sizes so every copy in your code is provably bounded. - How to spot and fix the classic "`strncpy` forgot the NUL" bug.
A C string is just an array of char that ends with a special marker byte called the NUL terminator, written \0 (a byte whose value is 0). In the prerequisite lesson Implementing strlen you saw that string functions find the end of a string by scanning forward until they hit that \0. Copying a string works the same way: you keep moving bytes from a source until you have copied the terminator.
strcpy (declared in <string.h>) is the standard library function that does exactly this. You give it two pointers — a destination and a source — and it copies the source into the destination, terminator included. It is one of the very first string functions beginners meet, and it looks harmless.
The problem is what strcpy does not do: it never asks how big the destination is. It trusts that wherever dst points has enough room. In C, arrays do not carry their size with them — a char * is just an address, with no length attached. So strcpy will happily write 100 bytes into an 8-byte buffer and keep going, scribbling over whatever memory sits after it. That single design choice is why strcpy appears in security advisories decade after decade.
This lesson teaches what strcpy actually does internally, why the missing size check is so dangerous, and the bounded alternatives you should reach for instead. Everything here builds directly on the idea of NUL-terminated strings from the strlen lesson and prepares you for comparing and searching strings in later lessons such as strcmp.
Almost every program handles text it did not write itself: usernames, file paths, command-line arguments, data from files, bytes off a network socket. Whenever you move that text from one place to another, you are copying a string. If the copy is unbounded, an input that is longer than you expected can overwrite adjacent variables, return addresses on the stack, or heap bookkeeping data.
A buffer overflow is not just a crash. In the worst case it lets an attacker overwrite a function's saved return address and redirect the program to code of their choosing — this is the foundation of classic stack-smashing attacks. Even when it cannot be turned into code execution, it corrupts data and produces bugs that are maddening to diagnose because the symptom shows up far from the cause.
The lesson is practical, not academic: strcpy, gets, strcat, and sprintf (the unbounded form) are responsible for a large share of historical memory-corruption CVEs. Modern compilers warn about them, security linters flag them, and many codebases ban them outright. Learning to write bounded copies from the start means you never ship that class of bug.
A string copy moves the bytes of one NUL-terminated string into another buffer so the destination holds an independent copy of the same text. "Independent" matters: after copying, changing one string does not affect the other, because they live in different memory.
Internally a copy is a loop: read a byte from the source, write it to the destination, advance both pointers, and stop after copying the \0. Here is the conceptual loop strcpy performs:
src: 'h' 'i' '\0'
| | |
v v v (copy each byte, then advance)
dst: 'h' 'i' '\0' ? ? ? ? <- dst[3..] left untouched
The terminator is what tells the loop when to stop. If the source has no \0 within the destination's bounds, the loop has no reason to stop in time.
When to use a plain copy: when you genuinely control both buffers and can prove the destination is large enough. When NOT to: any time the source length is not under your control — user input, file contents, network data.
Pitfall: confusing copying with assignment. dst = src; on two char * only copies the pointer (the address), so both names point at the same bytes — it does not duplicate the text.
Knowledge check: In your own words, what single condition makes the copy loop stop, and what goes wrong if that condition is never met inside the destination?
strcpy: the unbounded standard functionchar *strcpy(char *dst, const char *src); copies bytes from src to dst up to and including the first \0, then returns dst. It assumes — without checking — that dst points to a region at least strlen(src) + 1 bytes long (the +1 is for the terminator).
The danger is structural: the function signature does not even include a size parameter, so there is no way for strcpy to bound itself. The caller is fully responsible for guaranteeing the space.
buffer 'dst' is 8 bytes:
[ d ][ s ][ t ][ 0..7 ][ ][ ][ ][ ]
strcpy(dst, "this is way too long") writes 21 bytes:
[ t ][ h ][ i ][ s ][ ][ i ][ s ][ ]| w ][ a ][ y ] ...
^^^^^^^^^^^^^^^^
OVERFLOW: bytes past the
buffer overwrite other memory
When to use: in modern code, essentially never on untrusted data. Some codebases allow it only for compile-time constant strings copied into a buffer that is provably larger. When NOT to: any variable-length or externally-supplied source.
Pitfall: "I checked it once and it fit." A buffer that is big enough for today's input is a time bomb when tomorrow's input is longer. The size guarantee must hold for all possible inputs, not the one you tested.
A buffer overflow is writing past the end of an allocated region. Memory is laid out so that other live data sits immediately after your buffer. Overflowing stomps on it.
Stack frame (grows toward lower addresses):
high addr
+------------------+
| saved return addr| <- overwrite this and you control where
+------------------+ the function 'returns' to (code execution)
| saved frame ptr |
+------------------+
| other locals |
+------------------+
| char dst[8] | <- overflow starts here and climbs upward
+------------------+
low addr
The consequences range from a corrupted neighbouring variable (subtle logic bug), to a crash from writing into unmapped memory (segfault), to a hijacked return address (security exploit). Because the write happens silently, the program may run fine for a while and fail much later — far from the real cause.
Pitfall: assuming a program that "works" is safe. An overflow that lands on currently-unused padding may not crash today; that does not mean the memory is yours to write.
Knowledge check (predict the output): char a[4]; char b[4]; strcpy(a, "hello"); Is a long enough? How many bytes does "hello" need, counting the terminator, and which neighbouring object is at risk?
snprintf and strncpyThe fix is to always pass the destination size and never write more than that.
snprintf(dst, size, "%s", src) writes at most size bytes total and always NUL-terminates (as long as size > 0). If the source is too long it is truncated, not overflowed. It returns how many bytes it would have written, so a return value >= size tells you truncation happened.strncpy(dst, src, n) copies at most n bytes — but it does not add a terminator if src is n bytes or longer. You must terminate it yourself.| Function | Bounded? | Always NUL-terminates? | Tells you about truncation? |
|---|---|---|---|
strcpy |
No | Yes (if it fits) | No |
strncpy |
Yes | No | No (you must check) |
snprintf |
Yes | Yes (size > 0) | Yes (return value) |
When to use snprintf: almost always — it is the safest default for copying or formatting into a fixed buffer. When strncpy is acceptable: filling fixed-width records where trailing NUL-padding is intended, and you remember to terminate manually.
Pitfall: trusting strncpy to terminate. After strncpy(dst, src, sizeof dst);, if src filled the whole buffer, dst has no \0 and the next printf("%s", dst) reads off the end. Always follow with dst[sizeof dst - 1] = '\0';.
Knowledge check (find the bug): char dst[8]; strncpy(dst, "abcdefgh", sizeof dst); printf("%s\n", dst); What is missing, and what undefined behaviour can the printf trigger?
The relevant declarations live in <string.h> (for strcpy/strncpy) and <stdio.h> (for snprintf):
#include <string.h>
#include <stdio.h>
char *strcpy(char *dst, const char *src); // unbounded — avoid
char *strncpy(char *dst, const char *src, size_t n); // bounded, no auto NUL
int snprintf(char *dst, size_t size, const char *fmt, ...); // bounded + NUL
char buf[16];
snprintf(buf, sizeof buf, "%s", source); // sizeof buf gives the real capacity
// ^^^^^^^^^^ pass the size, never a hardcoded guess
Key points: sizeof buf works only when buf is a real array in scope, not a char * (a pointer's sizeof is the pointer size, not the buffer size). Once you pass a buffer to a function as char *, you must also pass its size as a separate parameter.
strcpy doesstrcpy(dst, src) copies bytes from src into dst. It keeps copying until it reaches the NUL terminator (the \0 byte that marks the end of a C string), and it copies that terminator too.
The problem: strcpy never checks how big dst is.
If src is longer than the buffer behind dst, strcpy writes past the end of that buffer. This is a buffer overflow — writing into memory you do not own.
This single function has been the cause of thousands of CVEs (publicly tracked security vulnerabilities) over the years.
snprintf(dst, size, "%s", src) — copies at most size bytes and always adds a NUL terminator.strncpy — bounded, but you must terminate it yourself (see the mistakes section).Best practice: track the size of every destination buffer, and always use bounded copy operations.
#include <stdio.h>
#include <string.h>
/* Safe bounded copy: returns 0 on success, -1 if the source had to be
truncated. dst is always NUL-terminated when dstsz > 0. */
int safe_copy(char *dst, size_t dstsz, const char *src) {
if (dst == NULL || src == NULL || dstsz == 0) {
return -1; /* defensive: reject bad arguments */
}
/* snprintf writes at most dstsz bytes and always terminates. */
int needed = snprintf(dst, dstsz, "%s", src);
if (needed < 0) {
return -1; /* encoding error from snprintf */
}
/* If snprintf wanted more room than we gave it, the result was cut. */
return ((size_t)needed >= dstsz) ? -1 : 0;
}
int main(void) {
char dst[8];
/* Case 1: source fits. */
if (safe_copy(dst, sizeof dst, "hi") == 0) {
printf("copied ok: \"%s\"\n", dst);
}
/* Case 2: source is too long — truncated, never overflowed. */
if (safe_copy(dst, sizeof dst, "this is way too long") != 0) {
printf("truncated to fit: \"%s\"\n", dst);
}
return 0;
}
What it does: safe_copy wraps snprintf so the destination size travels with the destination pointer. It validates its arguments, copies the source, and reports whether truncation occurred by comparing snprintf's return value against the buffer size.
Expected output:
copied ok: "hi"
truncated to fit: "this i"
(The truncated string is the first 7 characters of the source plus the terminator, exactly filling the 8-byte buffer.)
Edge cases: a dstsz of 0 means there is no room even for the terminator, so the function refuses. snprintf returns the length it would have produced, which is why needed >= dstsz is the truncation test rather than needed > dstsz.
Walkthrough of the key example.
char dst[8]; reserves 8 bytes on the stack. Their contents are indeterminate until written.safe_copy(dst, 8, "hi").dst == NULL || src == NULL || dstsz == 0 is all false, so execution continues.snprintf(dst, 8, "%s", "hi") writes 'h', 'i', '\0' into dst[0..2] and returns 2 (the length excluding the terminator).needed is 2. The test (size_t)2 >= 8 is false, so safe_copy returns 0 (success) and main prints copied ok: "hi".safe_copy(dst, 8, "this is way too long").snprintf(dst, 8, "%s", ...) writes at most 8 bytes: the first 7 source characters "this i" then a \0 in dst[7]. It returns 20 — the length it would have needed for the full source.needed is 20. The test (size_t)20 >= 8 is true, so safe_copy returns -1.main prints truncated to fit: "this i".Trace of dst after each case:
| step | dst[0..7] (readable) | return |
|---|---|---|
| after Case 1 | h i \0 ? ? ? ? ? |
0 |
| after Case 2 | t h i s i \0 |
-1 |
The critical point: in Case 2 no byte is ever written past dst[7], so the neighbouring stack memory is untouched. With a raw strcpy(dst, "this is way too long") the same input would have written 21 bytes and corrupted whatever followed dst.
// WRONG
char name[16];
strcpy(name, user_input); // user_input could be any length
Why it is wrong: nothing bounds the copy, so a long user_input overflows name. The code may pass every test with short names and then corrupt memory in production.
// CORRECT
char name[16];
snprintf(name, sizeof name, "%s", user_input); // truncates safely
Recognise it: any strcpy/strcat/sprintf whose source is not a compile-time constant deserves a second look. Compilers with -Wall and tools like -D_FORTIFY_SOURCE=2 will flag many of these.
strncpy to terminate// WRONG
char buf[8];
strncpy(buf, "abcdefgh", sizeof buf); // fills all 8 bytes, no '\0'
printf("%s\n", buf); // reads past the buffer
Why it is wrong: when the source length is >= n, strncpy copies exactly n bytes and adds no terminator. The later %s keeps reading until it stumbles on some stray zero byte, leaking adjacent memory or crashing.
// CORRECT
char buf[8];
strncpy(buf, "abcdefgh", sizeof buf);
buf[sizeof buf - 1] = '\0'; // force termination
printf("%s\n", buf); // prints "abcdefg"
Recognise it: every strncpy should be followed by an explicit dst[size - 1] = '\0';, or replaced with snprintf.
sizeof on a pointer// WRONG
void copy_into(char *dst, const char *src) {
snprintf(dst, sizeof dst, "%s", src); // sizeof dst == 8 (a pointer!)
}
Why it is wrong: inside the function dst is a pointer, so sizeof dst is the pointer's size (typically 8), not the buffer's capacity. You silently cap the copy at 8 bytes regardless of the real array.
// CORRECT
void copy_into(char *dst, size_t dstsz, const char *src) {
snprintf(dst, dstsz, "%s", src); // size passed explicitly
}
Recognise it: whenever a buffer crosses a function boundary, its size must travel as a separate parameter.
Compiler warnings. Build with -Wall -Wextra. With glibc, adding -D_FORTIFY_SOURCE=2 -O2 makes the compiler insert run-time checks that abort on detectable overflows in strcpy/snprintf-family calls. Many compilers also emit a deprecation/format warning when a %s argument can exceed the buffer.
Runtime tools. Compile with -fsanitize=address (AddressSanitizer) and run normally. An overflow is reported the instant it happens with a message like stack-buffer-overflow plus the exact line and the bytes involved — far more useful than a delayed mystery crash. Valgrind catches many heap-buffer overflows similarly.
Typical errors and what they mean:
strncpy trap). Check that every buffer you print is terminated.snprintf did its job but the buffer was too small. Inspect its return value; >= size means truncation.Questions to ask when it does not work:
\0)?sizeof of a real array, or accidentally of a pointer?strncpy, did I terminate manually?snprintf's return value to detect truncation when it matters?This topic is a memory-safety topic, so the concerns are central rather than incidental.
strcpy writes until the source's \0. If that terminator is beyond the destination's end, every extra byte is undefined behaviour and may corrupt neighbouring variables, heap metadata, or a saved return address. Always bound the write with the destination size.strcpy and strlen read past its end. Make sure any string you copy from is itself properly terminated.strncpy up to its capacity has no terminator; reading it as a string later is undefined behaviour. Terminate explicitly.dst points into a freed or out-of-scope array, the copy writes into dead memory. Ensure the destination is alive for the whole copy.+1 for the terminator and watch for overflow in len + 1 for very large len. Use size_t for sizes and compare against the buffer capacity, not a hardcoded number.Defensive habit: treat the destination size as part of the destination's identity — never copy without it in hand, and prefer snprintf, which makes truncation the failure mode instead of corruption.
Where this shows up. Parsing command-line arguments into fixed buffers, copying file paths, building log lines, reading record fields from a binary file format, assembling protocol headers in network code, and handling fixed-width fields in embedded firmware all involve copying strings into bounded storage. Historically, unbounded copies in exactly these places produced landmark vulnerabilities (for example, overflows in network daemons that became remote-exploitation chains). Modern operating systems, browsers, and TLS libraries have largely purged raw strcpy from new code for this reason; BSD-derived systems even added strlcpy/strlcat specifically to give a safer bounded copy with guaranteed termination.
Beginner best practices:
snprintf(dst, sizeof dst, "%s", src) for copying into a known array.strcpy, strcat, sprintf, or gets on data you did not author.strncpy, write the terminator yourself.sizeof on real arrays only; pass sizes across function boundaries.Advanced best practices:
snprintf's return value so truncation is detected and handled, not ignored.safe_copy above) so the size rule is enforced in one place and audited once.-Wall -Wextra -D_FORTIFY_SOURCE=2 in builds and -fsanitize=address in tests/CI to catch overflows automatically.Objective: copy a short constant into a buffer using a bounded call.
Requirements: declare char dst[32];, copy the literal "hello, world" into it with snprintf and sizeof dst, then print dst.
Expected output: hello, world
Constraints: no strcpy. Hint: the format string is just "%s". Concepts: bounded copy, sizeof on an array.
Objective: show when a copy was cut short.
Requirements: with char dst[6];, copy "truncate me" using snprintf. Capture its return value. Print the resulting dst, then print truncated if the return value indicates the source did not fit.
Example output:
trunc
truncated
Constraints: decide truncation from the return value, not by re-measuring. Hint: snprintf returns the length it would have written. Concepts: snprintf return value, truncation test.
strncpy bugObjective: make an unterminated strncpy safe.
Requirements: start from char buf[8]; strncpy(buf, "abcdefgh", sizeof buf);. Add exactly one line so that printf("%s\n", buf); reliably prints abcdefg with no trailing garbage.
Expected output: abcdefg
Constraints: keep the strncpy; do not switch to snprintf for this task. Hint: the missing piece is a single explicit terminator. Concepts: manual NUL termination, off-by-one indexing with sizeof buf - 1.
Objective: write int copy_str(char *dst, size_t dstsz, const char *src) returning 0 on a full copy and -1 on truncation or bad arguments, always leaving dst terminated when dstsz > 0.
Requirements: reject NULL pointers and dstsz == 0. Use snprintf internally. Demonstrate it in main with one fitting and one truncating call, printing the result and the return code each time.
Constraints: the size must be a parameter, never sizeof dst inside the helper. Hint: compare the return value to dstsz. Concepts: passing sizes across functions, defensive argument checks.
strlcpy from scratchObjective: implement size_t my_strlcpy(char *dst, const char *src, size_t dstsz) matching the BSD strlcpy contract: copy as much of src as fits, always NUL-terminate when dstsz > 0, and return strlen(src) (the total length it tried to copy, so callers can detect truncation when the return value >= dstsz).
Requirements: do not call strcpy/strncpy/snprintf — write the copy loop yourself. Handle dstsz == 0 (copy nothing, but still return strlen(src)).
Example: copying "hello" into a 3-byte buffer yields dst == "he" and a return value of 5.
Constraints: never write outside dst[0 .. dstsz-1]. Hint: copy while there is room and the source byte is non-zero, then place the terminator, then finish measuring the source. Concepts: manual bounded loops, the terminator, returning intended length for truncation detection.
\0 terminator into a separate buffer; the terminator is what stops the copy loop.strcpy(dst, src) has no size parameter and never checks dst's capacity. If src is longer than dst, it overflows the buffer — a buffer overflow that can corrupt neighbouring memory or, in the worst case, hand control to an attacker.snprintf(dst, sizeof dst, "%s", src): it is bounded, always NUL-terminates when the size is positive, and its return value tells you when the source was truncated.strncpy is bounded but does not terminate when the source fills the buffer — always follow it with dst[size - 1] = '\0'.sizeof only on real arrays; once a buffer becomes a char * parameter, pass its size alongside it.