You have to take into account that 1.0e28f is a decimal (base 10) representation.
Converting this string (either a literal in source code or value in a data file) to a floating point imply a conversion to base 2 (the float internal representation).
The 3 nearest floating point values in the neighbourhood of 10^28 are exactly
0.9999998261528069050908803072e28f
0.9999999442119689768320106496e28f <- the nearest one
1.0000000622711310485731409920e28f
or in base 2:
1.00000010011111100111000 * 2^93
1.00000010011111100111001 * 2^93
1.00000010011111100111010 * 2^93
The nearest one (in the middle) is chosen and that is the float in memory, not 1.0e28.
When you request ToString(), you are converting back 0.9999999442119689768320106496e28f to a decimal form.
It sounds like default format is to print 1 decimal before the floating point separator, and 6 decimals after the floating point (much like C printf %f format)
The nearest such decimal to 0.9999999442119689768320106496e28f is indeed 9.999999e27, that's pretty straight forward.
EDIT
Note that 5^3 is near 2^7, since single precision float has 24 bits significand, and 24*3/7 is approximately 10, the maximum power of 5 represented exactly in single precision float is 5^10.
Hence, the maximum power of 10 representable exactly in float is 1.0e10.
Every power from 11 to 27 did print like you expect somehow by luck.
Well, not exactly by luck, but because the roundoff error was less than 0.5*10^(n-7).
EDIT 2 - about double
And double does not really "solve" things, because with 53 bits significand you can go up to 53*3/7 that is 10^22 without rounding error.
Every power of 10 from 23 and above are subject to round off.
It somehow solves however if you keep printing 6 decimals, because the round off occurs around the 15th digit.