I did some further investigation - unfortunately, the CP's behavior appears to have changed on me during testing, and now I can't be sure I'm testing accurately. I seem to have fixed the problem, but I don't quite understand why.
I got into the CorePlugins project, and edited ChangePassword.ascx.cs, the code-behind for that page. I noticed this section:
if (Utilities.RemoveDCFromLdap(PreferredDC, CurrentUser.LdapPath) == Utilities.RemoveDCFromLdap(PreferredDC, CurrentContext.LDAPPath)) {
pnlChange.Visible = true;
DefaultControl = txtOld;
}
else {
pnlSet.Visible = true;
DefaultControl = txtCPassword;
}
I figured I'd try hard-coding it, so I commented the whole section and copied the pnlChange version below. This affects Submit_Click, so that changePassword is called rather than setPassword. (After that it gets into the HostedExchange namespace, and from thence I'm sure it's further occluded by an MPF call, and I didn't want to mess around with the guts of the thing this time.)
As expected, when I logged on as a restricted user this time I got all three fields on the Change Password page: Old Password, New Password, Confirm. I successfully changed my password *, but this is when things got weird, unfortunately. I uncommented the code above, changing it back to its original state, but when I tried again as the same user, I now get the 3-field version of the form! For some reason, the logic in that if statement has actually changed after just once invoking the 3-field version of the form, albeit doing so very manually. Now it works for all of my users. 
Hope this helps someone. I might have to give up on it, as I can't even duplicate the problem, now.
* - Note: If the user does not have rights to change their own password, you'll get the "Constraint violation occurred" error here. This appears wholly unrelated to the original problem, which is the "Access denied" version of the error, that occurs regardless when the user is presented with the two-field version of the form.