-
Notifications
You must be signed in to change notification settings - Fork 54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] Basic SMTP/IMAP probe: look for bad TLS and StartTLS downgrade attacks #989
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,325 @@ | ||||||
package imap | ||||||
|
||||||
import ( | ||||||
"bufio" | ||||||
"context" | ||||||
"crypto/tls" | ||||||
"fmt" | ||||||
"github.com/pkg/errors" | ||||||
"net/url" | ||||||
"strings" | ||||||
"time" | ||||||
|
||||||
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" | ||||||
"github.com/ooni/probe-cli/v3/internal/measurexlite" | ||||||
"github.com/ooni/probe-cli/v3/internal/model" | ||||||
"github.com/ooni/probe-cli/v3/internal/tcprunner" | ||||||
"github.com/ooni/probe-cli/v3/internal/tracex" | ||||||
) | ||||||
|
||||||
var ( | ||||||
// errNoInputProvided indicates you didn't provide any input | ||||||
errNoInputProvided = errors.New("not input provided") | ||||||
|
||||||
// errInputIsNotAnURL indicates that input is not an URL | ||||||
errInputIsNotAnURL = errors.New("input is not an URL") | ||||||
|
||||||
// errInvalidScheme indicates that the scheme is invalid | ||||||
errInvalidScheme = errors.New("scheme must be imap(s)") | ||||||
) | ||||||
|
||||||
const ( | ||||||
testName = "imap" | ||||||
testVersion = "0.0.1" | ||||||
) | ||||||
|
||||||
// Config contains the experiment config. | ||||||
type Config struct{} | ||||||
|
||||||
type runtimeConfig struct { | ||||||
host string | ||||||
port string | ||||||
forcedTLS bool | ||||||
noopCount uint8 | ||||||
} | ||||||
|
||||||
func config(input model.MeasurementTarget) (*runtimeConfig, error) { | ||||||
if input == "" { | ||||||
// TODO: static input data (eg. gmail/riseup..) | ||||||
return nil, errNoInputProvided | ||||||
} | ||||||
|
||||||
parsed, err := url.Parse(string(input)) | ||||||
if err != nil { | ||||||
return nil, fmt.Errorf("%w: %s", errInputIsNotAnURL, err.Error()) | ||||||
} | ||||||
if parsed.Scheme != "imap" && parsed.Scheme != "imaps" { | ||||||
return nil, errInvalidScheme | ||||||
} | ||||||
|
||||||
port := "" | ||||||
|
||||||
if parsed.Port() == "" { | ||||||
// Default ports for StartTLS and forced TLS respectively | ||||||
if parsed.Scheme == "imap" { | ||||||
port = "143" | ||||||
} else { | ||||||
port = "993" | ||||||
} | ||||||
} else { | ||||||
// Valid port is checked by URL parsing | ||||||
port = parsed.Port() | ||||||
} | ||||||
|
||||||
validConfig := runtimeConfig{ | ||||||
host: parsed.Hostname(), | ||||||
forcedTLS: parsed.Scheme == "imaps", | ||||||
port: port, | ||||||
noopCount: 10, | ||||||
} | ||||||
|
||||||
return &validConfig, nil | ||||||
} | ||||||
|
||||||
// TestKeys contains the experiment results for an entire domain host | ||||||
type TestKeys struct { | ||||||
Host string `json:"hostname"` | ||||||
Queries []*model.ArchivalDNSLookupResult `json:"queries"` | ||||||
// Individual IP/port results | ||||||
Runs []*IndividualTestKeys `json:"runs"` | ||||||
// Used for global failure (DNS resolution) | ||||||
Failure string `json:"failure"` | ||||||
} | ||||||
|
||||||
func newTestKeys(host string) *TestKeys { | ||||||
tk := new(TestKeys) | ||||||
tk.Host = host | ||||||
return tk | ||||||
} | ||||||
|
||||||
// Hostname TCPRunnerModel | ||||||
func (tk *TestKeys) Hostname(host string) { | ||||||
tk.Host = host | ||||||
} | ||||||
|
||||||
// DNSResults TCPRunnerModel | ||||||
func (tk *TestKeys) DNSResults(res []*model.ArchivalDNSLookupResult) { | ||||||
// TODO: not sure if we are passed the overall trace results and should overwrite key, or just append | ||||||
tk.Queries = append(tk.Queries, res...) | ||||||
} | ||||||
|
||||||
// Failed TCPRunnerModel | ||||||
func (tk *TestKeys) Failed(msg string) { | ||||||
tk.Failure = msg | ||||||
} | ||||||
|
||||||
// NewRun TCPRunnerModel | ||||||
func (tk *TestKeys) NewRun(addr string, port string) tcprunner.TCPSessionModel { | ||||||
itk := newIndividualTestKeys(addr, port) | ||||||
tk.Runs = append(tk.Runs, itk) | ||||||
return itk | ||||||
} | ||||||
|
||||||
// IndividualTestKeys contains the experiment results for a single IP/port combo | ||||||
type IndividualTestKeys struct { | ||||||
TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` | ||||||
TLSHandshake *model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` | ||||||
Failure string `json:"failure"` | ||||||
FailureStep string `json:"failed_step"` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
IP string `json:"ip"` | ||||||
Port string `json:"port"` | ||||||
noopCounter uint8 | ||||||
} | ||||||
|
||||||
func newIndividualTestKeys(addr string, port string) *IndividualTestKeys { | ||||||
itk := new(IndividualTestKeys) | ||||||
itk.IP = addr | ||||||
itk.Port = port | ||||||
return itk | ||||||
} | ||||||
|
||||||
// IPPort TCPSessionModel | ||||||
func (itk *IndividualTestKeys) IPPort(ip string, port string) { | ||||||
itk.IP = ip | ||||||
itk.Port = port | ||||||
} | ||||||
|
||||||
// ConnectResults TCPSessionModel | ||||||
func (itk *IndividualTestKeys) ConnectResults(res []*model.ArchivalTCPConnectResult) { | ||||||
itk.TCPConnect = append(itk.TCPConnect, res...) | ||||||
} | ||||||
|
||||||
// HandshakeResult TCPSessionModel | ||||||
func (itk *IndividualTestKeys) HandshakeResult(res *model.ArchivalTLSOrQUICHandshakeResult) { | ||||||
itk.TLSHandshake = res | ||||||
} | ||||||
|
||||||
// FailedStep TCPSessionModel | ||||||
func (itk *IndividualTestKeys) FailedStep(failure string, step string) { | ||||||
itk.Failure = failure | ||||||
itk.FailureStep = step | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you really need setters for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I introduced this helper method to be sure i wouldn't miss one of the two keys when reporting an error. Is this unreasonable? Also, the field is named FailureStep precisely because this method FailedStep creates a naming conflict. I could rename it of course if you think it's wise to keep this helper method. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding to what @hellais said: The use case where you need setters I can think of is the one in which you're running operations in parallel and therefore want to protect the test keys with a mutex. In such a case, I suggest this pattern: type TestKeys struct {
Foo string `json:"foo"`
mu *sync.Mutex // not exported because lowercase
}
func (tk *TestKeys) setFoo(value string) {
defer tk.mu.Unlock()
tk.mu.Lock()
tk.Foo = value
} For cases in which you're running operations in parallel, I would recommend setting keys directly. |
||||||
|
||||||
// Measurer performs the measurement. | ||||||
type Measurer struct { | ||||||
// Config contains the experiment settings. If empty we | ||||||
// will be using default settings. | ||||||
Config Config | ||||||
|
||||||
// Getter is an optional getter to be used for testing. | ||||||
Getter urlgetter.MultiGetter | ||||||
} | ||||||
|
||||||
// ExperimentName implements ExperimentMeasurer.ExperimentName | ||||||
func (m Measurer) ExperimentName() string { | ||||||
return testName | ||||||
} | ||||||
|
||||||
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion | ||||||
func (m Measurer) ExperimentVersion() string { | ||||||
return testVersion | ||||||
} | ||||||
|
||||||
// Run implements ExperimentMeasurer.Run | ||||||
func (m Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error { | ||||||
sess := args.Session | ||||||
measurement := args.Measurement | ||||||
log := sess.Logger() | ||||||
trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved) | ||||||
|
||||||
config, err := config(measurement.Input) | ||||||
if err != nil { | ||||||
// Invalid input data, we don't even generate report | ||||||
return err | ||||||
} | ||||||
|
||||||
tk := newTestKeys(config.host) | ||||||
measurement.TestKeys = tk | ||||||
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) | ||||||
defer cancel() | ||||||
|
||||||
tlsconfig := tls.Config{ | ||||||
InsecureSkipVerify: false, | ||||||
ServerName: config.host, | ||||||
} | ||||||
|
||||||
runner := &tcprunner.TCPRunner{ | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should eventually put something like this into the standard measurement library. I think putting it as an @bassosimone where do you think something like this could go? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Based on a quick skim through the package implementation itself, this code seems to me an abstraction built on top of BTW, the fact that @ooninoob felt the need to write a support package is very precious information. Our previous assumption was that people were going to write experiments using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be useful if i wrote about my personal feelings as a newcomer regarding the internal APIs? I could open a new ticket about this. Please note that i'm still not confident about what makes a good data structure for the test keys when it comes to testing multiple things (several hostnames and/or IP/port combos, as in these probes and the DHT/Bittorrent probes) and that consideration very much affects how an internal API should feel like. I don't know what is a good answer and i for sure can't come up with specific guidelines, but it feels to me like there's different ways to approach the testkeys organization from a high-level perspective:
It would sure be possible to have explicit guidelines about this, but just for starting to develop things we could have high-level APIs for several of those modes of operation. I think exposing a failed_step/failed_run as part of those high-level APIs would also bring value to help produce better test results (and better debugging abilities) without having each probe reinvent the wheel. That is my personal opinion that probes should focus on their probing logic (networking/protocol stuff) and not have to know/care about ooni internals and how data is magically collected and presented. This would for sure lower the barrier for contribution for new probes, although i would understand if the ooni project willingly prefers to keep the barrier high and the implementation details explicit. I just felt from talking to @hellais and from reading your comment that it may not be a conscious design decision to keep me (and other future contributors) buried under ooni internal abstractions and that all this error-prone boilerplate may have been accidental ;-) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ooninoob thank you for your very detailed reply! Some comments inline:
I am not sure sure about what you mean here. Are you complaining about the data format being inconsistent across experiments or are you referring to the APIs available for writing experiments?
I agree with the need to allow a programmer to focus on the network experiment logic. I find it to insightful to see you would like our documentation to spend less time about how data is collected and presented. I see why the current documentation focuses on that and why you rightfully say you should be (too much) concerned with that.
We previously had a high level API, which we now have deprecated. The reason why we deprecated it is that it was and is a burden to maintain in light of having a number of experiments to maintain. Each experiment has some set of semi-conflicting requirements, which shapes and puts pressure on such API. You ended up using directly the internal primitives (aka "step-by-step") that we determined are "better", where better is defined in terms of the kind of experiments (and follow-up experiments) we can write. We were not sure whether there was a need to add another API on top of that or not. Your experience tells us that we definitely need to offer some kind of API for people to write experiments that is more abstract.
Yeah, it's not a conscious decision. I suppose it's more of a learning process where, on the one end, we need to adapt our tools for measuring censorship and detecting which low-level step failed and possibly try to run some follow-up test. On the other end, we also need to provide contributors with easy to use abstractions. There's definitely a time lag in there and it's also true that it's difficult to know the direction without feedback, so thank you for your feedback! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Oh, @ooninoob, if you have time to chat about this, perhaps we can have a chat on Slack and then distill the result of the chat in some issue or design document to improve the contributing experience.) |
||||||
Tk: tk, | ||||||
Trace: trace, | ||||||
Logger: log, | ||||||
Ctx: ctx, | ||||||
Tlsconfig: &tlsconfig, | ||||||
} | ||||||
|
||||||
// First resolve DNS | ||||||
addrs, success := runner.Resolve(config.host) | ||||||
if !success { | ||||||
return nil | ||||||
} | ||||||
|
||||||
for _, addr := range addrs { | ||||||
tcpSession, success := runner.Conn(addr, config.port) | ||||||
if !success { | ||||||
continue | ||||||
} | ||||||
defer tcpSession.Close() | ||||||
|
||||||
if config.forcedTLS { | ||||||
log.Infof("Running direct TLS mode to %s:%s", addr, config.port) | ||||||
|
||||||
if !tcpSession.Handshake() { | ||||||
continue | ||||||
} | ||||||
|
||||||
// Try NoOps | ||||||
if !testIMAP(tcpSession, config.noopCount) { | ||||||
continue | ||||||
} | ||||||
} else { | ||||||
log.Infof("Running StartTLS mode to %s:%s", addr, config.port) | ||||||
|
||||||
// Upgrade via StartTLS and try NoOps | ||||||
if !tcpSession.StartTLS("A1 STARTTLS\n", "TLS") { | ||||||
continue | ||||||
} | ||||||
|
||||||
if !testIMAP(tcpSession, config.noopCount) { | ||||||
continue | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
return nil | ||||||
} | ||||||
|
||||||
func testIMAP(s *tcprunner.TCPSession, noop uint8) bool { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very bike-sheddy comment, but maybe these functions should not be named with the |
||||||
// Auto-choose plaintext/TCP session | ||||||
// TODO: move to Debugf | ||||||
s.Runner.Logger.Infof("Retrieving existing connection") | ||||||
conn := s.CurrentConn() | ||||||
s.Runner.Logger.Infof("Starting IMAP query") | ||||||
|
||||||
command, err := bufio.NewReader(conn).ReadString('\n') | ||||||
if err != nil { | ||||||
s.FailedStep(*tracex.NewFailure(err), "imap_wait_capability") | ||||||
return false | ||||||
} | ||||||
|
||||||
if !strings.Contains(command, "CAPABILITY") { | ||||||
s.FailedStep(fmt.Sprintf("Received unexpected IMAP response: %s", command), "imap_wrong_capability") | ||||||
return false | ||||||
} | ||||||
|
||||||
s.Runner.Logger.Infof("Finished starting IMAP") | ||||||
|
||||||
if noop > 0 { | ||||||
// Downcast TCPSession's itk into typed IndividualTestKeys to access noopCounter field | ||||||
concreteITK := s.Itk.(*IndividualTestKeys) | ||||||
s.Runner.Logger.Infof("Trying to generate more no-op traffic") | ||||||
concreteITK.noopCounter = 0 | ||||||
for concreteITK.noopCounter < noop { | ||||||
concreteITK.noopCounter++ | ||||||
s.Runner.Logger.Infof("NoOp Iteration %d", concreteITK.noopCounter) | ||||||
_, err = conn.Write([]byte("A1 NOOP\n")) | ||||||
if err != nil { | ||||||
s.FailedStep(*tracex.NewFailure(err), fmt.Sprintf("imap_noop_%d", concreteITK.noopCounter)) | ||||||
break | ||||||
} | ||||||
} | ||||||
|
||||||
if concreteITK.noopCounter == noop { | ||||||
s.Runner.Logger.Infof("Successfully generated no-op traffic") | ||||||
return true | ||||||
} | ||||||
s.Runner.Logger.Warnf("Failed no-op traffic at iteration %d", concreteITK.noopCounter) | ||||||
return false | ||||||
} | ||||||
|
||||||
return true | ||||||
} | ||||||
|
||||||
// NewExperimentMeasurer creates a new ExperimentMeasurer. | ||||||
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { | ||||||
return Measurer{Config: config} | ||||||
} | ||||||
|
||||||
// SummaryKeys contains summary keys for this experiment. | ||||||
// | ||||||
// Note that this structure is part of the ABI contract with ooniprobe | ||||||
// therefore we should be careful when changing it. | ||||||
type SummaryKeys struct { | ||||||
//DNSBlocking bool `json:"facebook_dns_blocking"` | ||||||
//TCPBlocking bool `json:"facebook_tcp_blocking"` | ||||||
IsAnomaly bool `json:"-"` | ||||||
} | ||||||
|
||||||
// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. | ||||||
func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { | ||||||
sk := SummaryKeys{IsAnomaly: false} | ||||||
_, ok := measurement.TestKeys.(*TestKeys) | ||||||
if !ok { | ||||||
return sk, errors.New("invalid test keys type") | ||||||
} | ||||||
return sk, nil | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps instead of setting a default port, we should throw an error due to a malformed input.
Since we generally use the input column for searching and filtering through measurements, a measurement run with as input
imap://gmail.com
will not be grouped together with a measurement with inputimap://gmail.com:143
.